Skip to content

[CodeGen] Keep the register clears at the machine level - #14

Draft
claude[bot] wants to merge 15 commits into
zeroize-scratch-regsfrom
zeroize-machine-dce
Draft

[CodeGen] Keep the register clears at the machine level#14
claude[bot] wants to merge 15 commits into
zeroize-scratch-regsfrom
zeroize-machine-dce

Conversation

@claude

@claude claude Bot commented Aug 12, 2026

Copy link
Copy Markdown

Requested by Francesco Bertolaccini · Slack thread

A register clear is an instruction whose only effect is to overwrite a register
nothing reads afterwards, which is the definition of a dead instruction. It is
emitted precisely because nothing reads the register: the value being destroyed
is one the function is finished with, and if anything still needed it the clear
would be wrong. So the machine-level reason to keep it is missing from the
instruction itself, and it survives for as long as nothing looks. This is the
machine-level counterpart of the non-removability llvm.zeroize already carries
in the IR, and it is a separate problem, because an intrinsic's properties stop
meaning anything once it has been lowered to an XOR.

What supplies the missing reason is the exit. The clears run in front of the
instruction control leaves through, and recording the cleared registers as
implicit uses of a return says what is in fact true: their values at the point
of leaving are part of what the function leaves behind, so an instruction that
sets them has an effect that outlives the function. A pass that removes an
instruction because its definitions are dead then finds them live and leaves
the clear alone.

Only a return carries the record. The other in-scope exits leave through a call
or through an instruction the classification could not read, and neither is a
place to state what the function leaves behind: a call's register operands
describe its arguments and an opaque instruction's describe whatever the target
or the asm string put there. The clears at those exits are left as they are,
which is what they are today at every exit, and the test pins that gap rather
than passing over it.

What is recorded is what the clear wrote, not what it was asked to write. The
request names every allocatable alias of a register, so x86 is asked for %rax,
%eax, %ax, %al and %ah and emits one instruction defining %eax; reading the
emitted instructions back is what gives the registers that exist. A clear also
writes registers on the side that are not part of what it destroys -- an XOR
sets the flags -- and those are excluded, because saying the function leaves
them behind would be saying something else and something untrue.

The order matters and is the one hazard here. The record is attached strictly
after the whole sequence has run at an exit, and so strictly after the register
clear has decided what it covers there. What it attaches sits between the
insertion point and the end of the block, which is exactly the range
computeRegsToClearAtExit reads to find the registers the exit still needs.
Attach first and the register clear spares precisely the registers it was about
to clear, and spares them silently: the function reports itself protected and
clears nothing. That is not a hypothetical -- it was tried, and every clear in
the function disappeared.

A register operand on an exit is not read only as liveness, which is why the
target gets a say in which register is named. On X86 the collision is real:
X86InsertVZeroUpper treats a return that names a YMM or ZMM register as a
return carrying a vector value and skips the vzeroupper it would otherwise put
in front of it, so naming a cleared YMM there drops the vzeroupper from every
AVX function that clears its registers and charges the caller an AVX-to-SSE
transition. CodeGen/X86/zero-call-used-regs-simd.ll catches it immediately.
Which registers a target reads off an exit, and for what, is not something a
target-independent pass can know, so it asks:
TargetFrameLowering::getClearedRegExitAnchor. The default is the register
itself. X86 answers a YMM or ZMM with its 128-bit part, which keeps the
definition live -- a definition is live as soon as any part of it is -- and
says nothing about vector state.

isBarrier was considered and is not the mechanism. It says that control does not
fall through the instruction: Target.td spells the field "Can control flow fall
through this instruction?" and MCInstrDesc documents it as whether execution
stops there. It is a statement about the CFG, it is what separates an
unconditional branch from a conditional one, it has nothing to say about whether
an instruction's effects may be discarded, it would be false of a clear, and
setting it would misdescribe the block to everything that reads it.

An extra instruction was tried in place of the operands and does not work.
FAKE_USE is the pseudo for exactly this -- "represents a use of the operand but
generates no code" -- it is explicitly exempted from
MachineInstr::wouldBeTriviallyDead, and it would cover every exit rather than
only the returns. But it is not free of output: the asm printer writes a
"# fake_use:" comment for it, so every function that clears registers grows a
line and a dozen existing tests that read the emitted code line by line have to
be rewritten. Recording the fact on an instruction that is already there costs
nothing.

How much this closes is worth stating exactly, because it is less than it
sounds. DeadMachineInstructionElim is the pass that removes instructions on
these grounds, and nothing runs it after prologue/epilogue insertion. Both of
its occurrences are in addMachineSSAOptimization, before register allocation,
in the legacy pipeline and in CodeGenPassBuilder alike. What does run after,
checked with -debug-pass=Structure on X86 and ARM at -O2 and at -O0, is
MachineLateInstrsCleanup, BranchFolder, TailDuplicate, MachineCopyPropagation,
the post-RA expansions and schedulers, block placement, the target's pre-emit
passes and the layout and metadata passes, and none of them removes an
instruction for having no live definitions. MachineLateInstrsCleanup comes
closest and declines an XOR before reaching the question, because its candidate
test rejects an instruction reading a register other than the frame register.

So this is not a fix for something the compiler does today. It is the property
being made to hold in the code rather than left to the pipeline's composition,
and the difference is visible rather than argued: run
DeadMachineInstructionElim over what this pass emits and, before this change,
every clear in the function goes.

That is what the test does. It stops after prologue/epilogue insertion, pipes
the result back through llc with -run-pass=dead-mi-elimination, and checks the
clears are still there afterwards, at a single return and at each of two. The
control is in the same file: the clear at an unwind-resume exit is not anchored,
and it is gone after the pass runs, which is what the clears at the returns
would do if the record were doing nothing. A fourth function pins the target's
substitution, checking both that the return names the 128-bit register and that
the function's vzeroupper is still emitted.

No existing test changes; CodeGen/X86 and CodeGen/ARM pass unchanged, as do the
tests this stack has added. The test was confirmed load-bearing by breaking the
implementation three times and restoring it: dropping the record failed it;
dropping X86's substitution failed it and took
CodeGen/X86/zero-call-used-regs-simd.ll with it; and attaching the record before
the clear set is computed failed it along with
CodeGen/X86/zero-call-used-regs.ll, with the emitted code losing every clear it
had. Nothing here is pinned on ARM, which implements no register clearing and
so has no clears to keep.

This is trailofbits/vspells-ct-internal-notes#66, under the umbrella
trailofbits/vspells-ct-internal-notes#17.

AI tool use

This pull request contains AI-generated content. It was prepared with the assistance of Claude Code; the contributor has reviewed the generated code and text, is the author of the contribution, and is accountable for it, per the LLVM AI Tool Use Policy.


Generated by Claude Code

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@github-actions

Copy link
Copy Markdown

Hello @claude[bot] 👋

Thank you for submitting a Pull Request (PR) to the LLVM Project. Since this is your first PR, here are a few useful links covering our main contribution policies and review practices.

  • All contributions to LLVM must follow our LLVM AI Tool Use Policy. In particular, if you used AI while working on this PR, remember to add a note to the PR description.
  • The LLVM Code-Review Policy and Practices document contains practical information about the PR process, including how patches are reviewed and accepted, and who can review a PR.
  • Our LLVM Developer Policy describes our expectations for code quality, commit summaries and contains notes on our CI system.

Please reply to this message to confirm that you have read these policies, especially the LLVM AI Tool Use Policy, and that any AI tool usage has been noted in the PR description.


Frequently asked questions

How do I add reviewers?

This PR will be automatically labeled, and the relevant teams will be notified. For some parts of the project, reviewers may also be added automatically.

You can also add reviewers manually using the Reviewers section on this page. If you cannot use that section, it is probably because you do not have write permissions for the repository. In that case, you can request a review by tagging reviewers in a comment using @ followed by their GitHub username.

What if there are no comments?

If you have not received any comments on your PR after a week, you can request a review by pinging the PR with a comment such as “Ping”. The common courtesy ping rate is once a week. Please remember that you are asking for volunteer time from other developers.

Are any special GitHub settings required to contribute to LLVM?

We only require contributors to have a public email address associated with their GitHub commits, see this section of LLVM Developer Policy for details.


If you have questions, feel free to leave a comment on this PR, or ask on LLVM Discord or LLVM Discourse.

Thank you,
The LLVM Community

claude added 2 commits August 12, 2026 07:19
Add an overloaded pointer-and-length intrinsic that writes zero over
[dest, dest + len) and guarantees the clear is emitted without calling an
external function and without establishing a call frame.

That codegen guarantee is the reason for the intrinsic. Clearing a buffer
by other means goes through memset, and a memset over a dynamic length or
a large fixed one lowers to a libcall. Measured on x86-64 with six values
live across the clear, the call forces all six into callee-saved registers
and pushes them in the prologue, grows the frame from 8 to 56 bytes, and
plants a callee frame below the stack pointer. A clear that spills the
live state it is trying to erase, into the stack it is trying to erase,
defeats the purpose. Expanded as a pseudo after register allocation the
same clear is a rep stosb with no libcall at any size.

Non-removability is part of the contract too, but it is not what makes the
intrinsic necessary and the previous version of this change oversold it. A
volatile memset is also not removable: DSEState::isRemovable refuses to
remove volatile memory intrinsics unconditionally, so a volatile memset
survives dead store elimination and a full pipeline in every shape the
test covers. What a volatile memset cannot do is lower without the
libcall.

llvm.memset.inline does not close the gap either. It survives dead store
elimination and lowers without a libcall, but SROA rewrites it back into a
plain memset on non-escaping stack allocas, which is exactly the case this
targets, and the no-libcall guarantee is gone before code generation. That
rewrite is legal because LangRef defines the two as equivalent.

llvm.memset.inline is also the precedent for adding an intrinsic whose
optimizer semantics duplicate an existing one. Its Intrinsics.td entry
carries byte-identical properties to int_memset and LangRef states its
behavior is equivalent to llvm.memset, with a codegen guarantee as the
whole of its justification. This is the same argument.

The survival half of the contract comes out of the declared memory effects
rather than out of changes to any pass. Claiming inaccessible memory in
addition to argument memory is more pessimistic than the intrinsic really
is, but a write that is not confined to argument pointees is not one dead
store elimination can attribute to a single location, so it has nothing to
remove, while the argument memory half keeps the write to the region
itself visible to alias analysis. llvm.prefetch is pessimistic in the same
way. IntrNoDuplicate keeps one clear from becoming several.

The test covers the three situations dead store elimination handles with
four intrinsics each: a plain memset, a volatile memset, a volatile
llvm.memset.inline and llvm.zeroize. The plain memset is removed
everywhere and llvm.zeroize survives everywhere; the volatile rows are
carried so the file does not read as claiming a distinction it does not
show, and the memset.inline rows pin the SROA rewrite. The lowering
guarantee is not an IR-level property and is tested with the lowering
change rather than here.

Nothing lowers the intrinsic yet; expanding it for a target is left to a
later change. The name is a recommendation still awaiting sign-off on
trailofbits/vspells-ct-internal-notes#64.
The property defeats the intrinsic it was meant to protect. InlineCost
refuses a call site whose callee contains a noduplicate call unless that
site is the sole call to a local function, where inlining deletes the
original and duplicates nothing. Every other site fails outright, with
cost=never and noduplicate as the reason. So a small internal helper that
wraps a clear and is called from more than one place, which is the natural
way to reach for this intrinsic, is refused at every one of its call sites,
and the clear ends up pinning open any caller it appears in. An intrinsic
whose purpose is to be dropped wherever a buffer needs erasing cannot also
be a barrier to inlining the code that erases it.

The justification did not hold either. Clearing a region is idempotent:
performing it twice leaves the region in the state one pass would have left
it, so there is nothing a duplicated clear can undo and no reason to keep
the call sites distinct.

The half of the contract that does matter, that the write survives even
where the region is provably never read again, does not rest on this
property and is unchanged. It comes from the declared memory effects, and
the dead store elimination test continues to pin it.
@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Author

My recommendation on this one is to drop the patch rather than fix it.

The bug first. The anchor gate is !Exit.MI->isReturn() (llvm/lib/CodeGen/PrologEpilogInserter.cpp:1692), not the MachineExitKind this stack introduced, and isReturn() is true for three of the five enforceable exit kinds. X86's TCRETURN* are isCall = 1, isReturn = 1 (X86InstrControl.td:283, :360), CATCHRET/CLEANUPRET are isReturn = 1, isEHScopeReturn = 1 (X86InstrCompiler.td:196), and isEnforceableExit is true for both TailCall and EHScopeReturn, so anchors land on tail calls and catchrets — the exact thing the comment at :1629-1636 forbids ("only of a return ... a call's register operands describe its arguments"). Verified with the prebuilt llc: a zero-call-used-regs="used-gpr" function ending in a tail call yields TCRETURNdi64 ... implicit $edi, implicit $eax, implicit $esi, and the anchors survive x86-expand-pseudo into TAILJMPd64, so the zeroed registers are recorded as inputs of the callee. On x86_64-pc-windows-msvc a catch funclet yields CATCHRET %bb.1, %bb.0, implicit $eax, implicit $edx, pinning in place the clear that destroys the continuation address. The one-line fix is Exit.Kind == MachineExitKind::Return, which is what the prose describes and what MachineFunctionExits.h was added for.

The reason not to bother: the patch's own analysis (:1740-1747) establishes that DeadMachineInstructionElim runs only inside addMachineSSAOptimization, pre-RA, and that nothing after prologue/epilogue insertion deletes an instruction for having no live definitions — so it fixes nothing the compiler actually does. What it costs is permanent and paid by everyone: a new virtual on TargetFrameLowering, an X86 override, and changed emitted MIR for every existing zero-call-used-regs user on the three targets that implement emitZeroCallUsedRegs (X86, AArch64, RISC-V). A hypothetical hazard does not buy a new target hook.

And if the property is worth expressing at all, it should be expressed once, uniformly, without a hook: FAKE_USE at every exit, or a check in MachineInstr::isDead/wouldBeTriviallyDead keyed off what PEI emitted — the natural home, since DeadMachineInstructionElim is the pass being defended against. Both cover all exits, including the ones this patch leaves unanchored. FAKE_USE is rejected here only on the grounds that "a dozen existing tests read the emitted code line by line", and test churn is not an argument against the correct mechanism.

Detail, and the rest of the findings on this patch, in the write-up: https://claude.ai/code/artifact/accb9215-36b5-4022-ae0f-249ee54e99cb


Generated by Claude Code

claude added 13 commits August 13, 2026 07:57
The entry argued for the intrinsic instead of specifying it. Drop the
rationale for why a clear must not go through memset, the comparison with
llvm.memset.inline, the explanation of why the usual grounds for discarding
a store do not apply, and the aside noting that a volatile llvm.memset.* is
not removable either. None of it constrains an implementation or a
transform; the normative statements it surrounded are kept verbatim.

Also drop the claim that the no-external-call, no-call-frame guarantee
"holds for every target". No target lowers llvm.zeroize yet, so nothing
backs the claim, and it stays wrong for the targets the stacked X86
expansion does not cover.

Documentation only; Intrinsics.td and the tests are unchanged.
Register a fixed metadata kind marking an alloca whose contents should
not be left readable in the stack frame once the function is done with
them. The metadata is only used as a flag: its presence on the alloca is
the entire signal and the node must be empty, with the contents of the
node reserved for future use.

The marked objects are what the "sensitive" mode of the "zeroize-stack"
attribute clears, so the LangRef entry is written around the direction
of the fallback between the two. The metadata buys precision and is not
itself part of the guarantee. Dropping it is always permitted, and where
the marked set has stopped describing the frame the response is to clear
more, at the limit every stack slot the function used, never to clear
less. Absence of the metadata on an object is not a statement that the
object is insensitive, and a transform may not shrink the set of objects
a function clears on the strength of what is or is not marked. What
makes losing the metadata safe is that the mode's floor already covers
every frame object with no source-level provenance, such as spill slots,
the callee-save area and alignment padding, whether or not anything is
marked.

Parsing, printing and bitcode serialization of metadata attachments are
generic, so the round-trip test needs no new parser code; registering
the kind gives consumers a fixed ID to look the attachment up by. No
pass or backend reads the metadata yet, and which transforms should
carry it across the objects they create is left to a later change.

The name is a recommendation still awaiting sign-off on
trailofbits/vspells-ct-internal-notes#64.
A function carrying the attribute promises to clear its own stack frame
before returning. Inlining it dissolves that frame into the caller's: the
bytes it promised to clear become bytes of a frame that outlives the
point where the clear was due, and nothing is left in the IR to record
the obligation. Until now the inliner would fold such a function into a
caller that clears nothing and drop the guarantee silently.

Refuse it where mismatched sanitizer instrumentation is already refused,
as a compatibility rule keyed on the IR attribute and consulted through
AttributeFuncs::areInlineCompatible. Placing the check there rather than
in a pass of its own is what makes it hold wherever inline compatibility
is decided, LTO and ThinLTO included; the test covers both, since the
callee reaching the inliner through the link rather than through its own
module is the case worth pinning down.

The rule is callee-side and has no same-attribute exemption. A caller
that carries the attribute clears its own frame at its own returns, which
is neither the clear the callee owed at the point it would have returned
nor necessarily as much of the frame, as the two functions may ask for
different amounts of it. Inlining a callee that does not carry the
attribute into one that does stays allowed and is worth having: the
callee's frame lies below the stack pointer once it returns and no clear
reaches it, whereas inlining turns those bytes into frame bytes of the
caller, which are cleared.

Rejecting alwaysinline combined with the attribute is left to the
frontend, which is where the combination can be diagnosed; alwaysinline
call sites bypass the attribute compatibility check in
getAttributeBasedInliningDecision.

The rule is the one decided on
trailofbits/vspells-ct-internal-notes#14.
The three constructs added for stack zeroization were accepted in whatever
shape they were written. A malformed one was therefore not a diagnostic but a
guarantee quietly reduced: an attribute naming no mode, or a mark on something
that is not a stack object, reads as a request that nothing ever honors.

Reject the shapes that are genuinely malformed. "zeroize-stack" selects how
much of the frame is cleared, so it has to name a mode, and the attribute with
no value names none. An unrecognized mode is deliberately still accepted:
LangRef gives it the meaning of "used", the widest mode, so that a mode string
a consumer has not learned yet widens what is cleared rather than narrows it.
A verifier error there would contradict that and give up the fail-safe for
nothing. Amend the LangRef sentence to say the value is required and may not be
empty, which is what the check enforces, so the two now agree.

!sensitive marks a stack object and its presence is the whole signal, so reject
it where there is no stack object to mark, on a non-alloca instruction, on a
function, on a global, and reject a payload while the contents of the node are
reserved. Ignoring operands today would let IR that relies on their being
ignored fix their meaning before anything wants to give them one.

llvm.zeroize needs no rule of its own. Intrinsic::isSignatureValid already
rejects a wrong return type, a wrong argument count, and a non-pointer or
non-integer argument, and the name is remangled from the signature on load, so
a check here would only restate the generic machinery.

Add bitcode round-trip tests for all three. The failure worth catching is a
mode string or an attachment that parses and is then lost or rewritten on
reload, which downgrades the guarantee without failing, so the tests check the
reloaded text against what was written, including an unrecognized mode, which
has to survive verbatim, rather than only that reloading succeeds.
Nothing expanded the intrinsic, so a function that used it did not build.
What makes expanding it different from expanding a memset is that the write
has to still be there at the end: the region is normally dead by the time the
clear runs, which is exactly the shape a store-removing pass looks for.

Emitting the stores at selection time and hoping they survive would rest on
every later pass declining to remove them. Emit an opaque pseudo instead.
ZEROIZE64 has no pattern and no memory operand, so no analysis can attribute a
store to it and no pass can find a store to prove redundant, and it carries
hasSideEffects and mayStore, so it is not dead either. X86ExpandPseudo turns it
into "movb $0, %al; rep;stosb" in addPreSched2, after register allocation and
so after every pass that deletes stores. Between selection and that point there
is no store to remove, and past that point nothing is left that would remove
one.

Both selectors reach the pseudo through the hook each already has for an
intrinsic needing custom handling rather than a generic call:
LowerINTRINSIC_W_CHAIN for ISD::INTRINSIC_VOID, which X86 already marks Custom,
and X86LegalizerInfo::legalizeIntrinsic, which until now accepted every
intrinsic unchanged. Each copies the destination and the count into %rdi and
%rcx and emits the pseudo, so the two produce the same machine code.

The tests pair each clear with an ordinary store the backend does remove, one
covered by a later store and one repeated, so what they pin is the difference
rather than only that some instructions came out. A second test walks the three
points that carry the guarantee: what each selector handed over, that it is
still the pseudo once registers are allocated, and that the write exists only
after the expansion.

The clearing sequence is LP64 only. 32-bit x86 still reports the intrinsic as
unlowered, and the sequences for other targets are separate work, on
trailofbits/vspells-ct-internal-notes#11.
A request to clear call-used registers is dispatched through
emitZeroCallUsedRegs, whose default body is empty. A target that has not
implemented it therefore compiles the request into nothing at all, and nothing
says so: the caller asked for the registers to be destroyed, the object file
leaves them holding what was in them, and the only way to find out is to read
the disassembly. ARM32 is the live case. The driver refuses the command-line
flag there through a hard-coded list of triples, but the function attribute
that flag turns into is accepted, carried through the whole pipeline, and
dropped in the frame lowering, so anything that sets the attribute directly,
including LTO and inlining of code compiled elsewhere, silently gets nothing.

Add a capability query the target answers instead of inferring support from an
emission that may do nothing. supportsZeroCallUsedRegs defaults to false, so a
target is unsupported until it says otherwise, and prologue-epilogue insertion
asks before computing what to clear: a target that answers false gets a
diagnostic and no code, rather than no diagnostic and no code. The three
targets that implement the emission today, X86, AArch64 and RISCV, answer true,
which is what the driver's triple list already assumed, so no target changes
what it generates.

supportsZeroizeStack is the same query for the "zeroize-stack" attribute, and
today every target answers false, because none of them clears the frame yet.
That attribute has had no backend consumer at all since it was added, which is
the same silence in a worse form, so it now reports itself as unsupported
everywhere until an implementation exists.

DiagnosticInfoUnsupported is what the backend already uses to refuse a request
it cannot compile, including in frame lowering, where RISCV reports a reserved
stack or frame pointer through it. It names the function, is an error rather
than a warning, and leaves llc exiting non-zero, which is what fails closed
means here.

The scope is the query surface and the refusal. Clearing the stack is
trailofbits/vspells-ct-internal-notes#26 and the set of registers to clear is
unchanged, on trailofbits/vspells-ct-internal-notes#27. The frontend
diagnostic that replaces the driver's triple list is
trailofbits/vspells-ct-internal-notes#67, which will consult these queries.
The names are recommendations awaiting sign-off on
trailofbits/vspells-ct-internal-notes#64.

This is trailofbits/vspells-ct-internal-notes#23, under the umbrella
trailofbits/vspells-ct-internal-notes#17.
Register clearing finds where to emit by walking the blocks whose last
instruction is a return, computing one register set for the whole function and
handing it to the target at each of them. Finding return blocks answers a
different question from the one a function asked to destroy its registers or
its frame needs answered, and the two answers differ in both directions.

A tail call is a call marked as a return on the targets that have one, so a
tail-call block is in the walk; but the epilogue has already run there, the
frame belongs to the callee and the outgoing arguments are live, so being
visited is not the same as being protected. In the other direction, a landing
pad that runs destructors leaves the function by calling the routine that
resumes unwinding, and a funclet leaves through cleanupret or catchret. Neither
is a return, so neither is in the walk, and no clearing happens on any unwind
path today.

Classify the exits rather than collect the return blocks. classifyMachineExit
gives a block one of seven kinds, decided by what the exit does with the frame
and by nothing else, because that is what decides whether a clearing sequence
can be placed at it. Four are in scope: a return that is not a call, a tail
call, a return out of an exception scope, and a call to the routine that
resumes unwinding. That last one is asked for by libcall, _Unwind_Resume or
__cxa_end_cleanup under the ARM EH ABI, rather than matched by name, so it is
recognised wherever DwarfEHPrepare would have created it.

Three are out of scope, for the same reason in each case: the frame is
abandoned rather than released, so there is no position at which a sequence
could run and still be the last thing to touch it. A call that does not return
here, whether abort, exit, a throw with no cleanup in this function or longjmp
reached as an ordinary call, hands the caller's context back through the
unwinder or through the jump with nothing of ours in between. A non-local jump
does the same by reloading another frame's stack and frame pointers. A trap, or
an empty block left behind by an unreachable, does not transfer out of the
frame at all. The threat model does not cover abandoned frames, and this is
where that exclusion is recorded rather than restated at each emission site.

Unwinding past a function that has no cleanup in it is excluded for a stronger
reason than policy: at this point it is not expressible. A call that may unwind
and is not caught here has no edge to anything in this function, so it is
indistinguishable from a call that does not unwind, and there is no instruction
a sequence could be attached to. A longjmp that crosses this frame from a
callee is the same. Nothing of the function runs, so nothing can be put in it.

Unreachable is a kind rather than an absence, so that a block which reaches the
end of the function without matching any other shape is recorded as classified
instead of being indistinguishable from a block the walk failed to reach.

The classification runs in prologue and epilogue insertion, immediately in
front of the existing register clearing, which is where registers are already
allocated, the frame is laid out and frame indices have not been eliminated
yet. Nothing consumes it yet, so it is computed only when -pei-print-exits asks
for it, and that flag is also how the tests observe it. It prints rather than
counting or tracing because a release build has neither statistics nor
-debug-only, and a classification that decides what gets protected has to be
checkable in the configuration a shipped compiler is built in. Each test pins a
whole function's exit list with CHECK-NEXT between the opening and closing
lines, so a kind that changes, an exit that appears and an exit that disappears
all fail; each of the five distinctions the classifier draws was removed in
turn, and each removal failed at least one test.

Nothing is emitted differently. The clearing walk, the register set it computes
and the diagnostics around it are untouched, the flag is off by default, and a
run without it produces no output at all.

Ordering the sequence at the return-shaped exits is
trailofbits/vspells-ct-internal-notes#19, tail calls are
trailofbits/vspells-ct-internal-notes#22, and emission on the unwind and
cleanup exits is trailofbits/vspells-ct-internal-notes#25.

This is trailofbits/vspells-ct-internal-notes#18, under the umbrella
trailofbits/vspells-ct-internal-notes#17.
Prologue and epilogue insertion emits register clearing at every block whose
last instruction is a return. It is one step with no defined relationship to
anything else that will be emitted there, which has cost nothing so far because
it is the only step there is, and stops being free with the second one.
Clearing the frame cannot be done without registers to hold the address it
stores through and the value it stores, so it ends leaving in registers what it
has just taken out of memory, and a register clear in front of it is undone by
it. Clearing the flags has to be behind everything, because a register is
cleared on x86 with an exclusive-or, which writes them, and a frame clear that
loops writes them too. Those are constraints between steps, and a single
emission has nowhere to record them.

Make the emission point a coordinator. ClearingSequence is the order:
ClearStack, then ClearRegisters, then ClearFlags. The emission walks that array
at each exit and dispatches each step, instead of calling the one step there
is. Two of the three emit nothing yet, and they are here rather than arriving
with their implementations because the order is what is being settled: a step
that arrives without one goes where it is convenient, and the reason it
belonged somewhere else then has to be found again. The comment above the
enumeration is where the reason for each position is written down, because
that, rather than the order itself, is what an implementer of the remaining
steps needs. It also records what the order is over. It is over the emitted
code, not over one insertion point: the steps that exist today all emit in
front of the instruction control leaves through, which is after the epilogue,
while clearing the frame has to happen before the epilogue moves the stack
pointer and the frame stops being addressable as the frame.

What each step does is decided once per function and before anything is
emitted, since a sequence whose steps differed between exits would not be one
sequence. A step is not requested, unsupported and reported, unimplemented, or
emitting. Planning is where the two capability queries are asked and where the
two fail-closed diagnostics are raised, so a function is told what will not
happen for it whether or not it has an exit to emit at. Nothing in the plan or
in the walk consults a value the function computes, so the sequence a protected
function runs is the same on every input.

Where the sequence runs is the exit classification, not a fresh walk over the
blocks that end in a return. The two answers differ in one direction here. A
tail call and a funclet return are both marked as returns in the instruction
description, so blocks ending in TCRETURN, CLEANUPRET or CATCHRET were already
reached by the walk and are reached by the classification too; nothing changes
at them. A landing pad that resumes unwinding ends in a call, which is not
marked as a return, so it was not reached, and a function unwinding out of a
cleanup left every register it had used to the unwinder. That exit is in scope,
and the sequence now runs at it.

Reaching it needs two things the block-scoped emission could not express. The
target used to choose where to emit, the first terminator of the block, and a
landing pad that ends in a call has no terminator, so the clearing would have
been appended after the call that leaves the function. Steps that choose their
own positions cannot be ordered against one another either, so the position is
the coordinator's and is passed to emitZeroCallUsedRegs. At an exit that leaves
through a terminator it is the first terminator, which is where the three
targets implementing the hook already emitted, so nothing they generate moves.
The other is the register set: it is computed once for the function with the
registers its returns read removed, and it does not know that the resume call
reads the exception object in an argument register. Clearing that register
would leave the unwinder nothing to resume with, so the registers an exit names
are dropped at that exit, which at a return exit removes nothing the
function-wide computation had not already removed. Computing the set per exit
instead of narrowing one computed for the function is
trailofbits/vspells-ct-internal-notes#21.

No existing test changes, anywhere in CodeGen/X86 or CodeGen/ARM. Nothing in
the tree combines "zero-call-used-regs" with an unwind path, which is the same
reason the gap lasted this long, so the new site is pinned by a new test rather
than by an old one starting to expect more. The order is pinned separately from
what is emitted, through -pei-print-clearing-sequence, which lists the steps in
sequence order with what each of them does at each in-scope exit; it is a flag
rather than a debug print for the reason -pei-print-exits is, that a sequence
deciding what gets destroyed has to be checkable in the configuration a shipped
compiler is built in. Each new test was confirmed load-bearing by breaking the
implementation once and restoring it: reordering the array, going back to the
walk over return blocks, dropping the out-of-scope filter, letting the target
pick the position again, dropping the per-exit narrowing, and skipping the
funclet exits each failed at least one of them.

The scope is the order and the sites. What the register clear emits is
unchanged, the scratch registers a frame clear will need are
trailofbits/vspells-ct-internal-notes#20, clearing the frame is
trailofbits/vspells-ct-internal-notes#26, and what a tail call needs beyond
being in scope is trailofbits/vspells-ct-internal-notes#22.

This is trailofbits/vspells-ct-internal-notes#19, under the umbrella
trailofbits/vspells-ct-internal-notes#17.
Register clearing decides what to destroy once for the function and emits that
decision at every exit. Part of it genuinely belongs to the function: which
registers a mode selects, and the callee-saved registers no exit may touch. The
rest does not. A register can be cleared only where it is dead, and where it is
dead is a property of one exit, so a set computed for the function has to take
the union over the function's returns and deny every exit what every other exit
needs.

A function with two returns is where that shows. Each of them needs its own
live-out registers and nothing else, but the set they are cleared from has both
returns' registers taken out of it, so each exit leaves the other's holding
whatever the function last put in them. That is the value the attribute exists
to destroy, sitting in a register that is dead at the exit actually taken. Taken
far enough the set empties: a function whose two exits are a return and a tail
call needs the return value in %eax at one of them and the outgoing arguments in
%edi and %esi at the other, the union of those is every register the function
used, and the function clears nothing at either exit while reporting that it is
protected.

Split the computation in two. planClearRegisters keeps what the mode decides,
which registers "used", "arg" and "gpr" select over the whole function, because
that is what those words have always meant, and the callee-saved exclusion,
which is the function's too: a callee-saved register has to hold what the
caller left in it wherever the function leaves, so no exit can clear one. What
comes out of it is a candidate set rather than a clear set, and
computeRegsToClearAtExit turns it into one at each exit.

What an exit needs is what runs after the sequence, and the sequence is emitted
at the exit's insertion point, so that is the rest of the block: the return and
the registers it carries the return value in, the jump of a tail call and the
registers it leaves the outgoing arguments in, or the call that resumes
unwinding and the argument register it takes the exception object in. Reading
that off the instructions rather than off a list of exit kinds is what keeps it
right for a kind added later. It also subsumes the narrowing that arrived with
the clearing sequence, which removed the registers named by the exit
instruction from a set computed for the function; there is no function-wide set
left to narrow.

The result never clears less than before. At an exit that leaves through a
terminator the new exclusion is that block's terminator run, which is one of
the terms the old union was taken over, so what is spared at that exit is a
subset of what was spared before and what is cleared is a superset of what was
cleared before. That was checked rather than argued: a temporary build
recomputed the old union-based set at every exit and failed the compilation if
the per-exit set was missing a register the old one had. Every test in
CodeGen/X86 and CodeGen/ARM passed under it, and the check was confirmed to
fire by weakening it on purpose. The scaffolding is not part of this change.

What the existing modes mean is unchanged. Which registers are candidates is
still answered by the same code from the same attribute, and only the exclusion
has moved; a function with one exit has nothing to take a union over, so its
output is identical, which is most of what is in the tree today. Compatibility
with the shipped modes is trailofbits/vspells-ct-internal-notes#60. The
register-unit reset in the exclusion is kept with its FIXME rather than
corrected, for the same reason: it only ever spares registers, so correcting it
would widen what every mode clears, which is a separate change from moving the
exclusion.

No existing test changes, in CodeGen/X86 or in CodeGen/ARM. Two functions in
llvm/test/CodeGen/X86 carry "zero-call-used-regs" and have more than one exit
in the IR, and both lower to a single machine exit, so there is nothing for
them to take a union over either. The zero-call-used-regs tests under
CodeGen/AArch64 and CodeGen/RISCV were not run, since neither target is built
here; every function in them has one exit, which is the case in which the two
computations agree by construction.

The new test is the contrast between the two exits of one function: the tail
call clears the return-value register the other exit needs, the return clears
the argument registers the tail call needs, and neither cleared anything
before. A second function makes the same contrast between a return and an exit
that resumes unwinding, and keeps the exception object the resume call reads,
which is the exit answering for itself rather than being told by another exit.
A third has one exit and emits what it always emitted. On ARM, which refuses to
clear registers at all, what is pinned is that the refusal is still the
function's: one diagnostic for a function with two exits, and the same
disposition reported at both. Each was confirmed load-bearing by breaking the
implementation once and restoring it: putting the union back, dropping the
per-exit exclusion, and planning the sequence at each exit instead of once each
failed one of them.

This is trailofbits/vspells-ct-internal-notes#21, under the umbrella
trailofbits/vspells-ct-internal-notes#17.
A tail call replaces the caller's frame with the callee's and jumps, so control
never comes back to the caller. A function carrying "zeroize-stack" has
undertaken to clear its frame before it returns, and a tail call takes away the
point at which it would do that: the frame stays live underneath the callee,
and the function reports itself protected while leaving in memory exactly what
the attribute exists to destroy. Tail-call optimization is suppressed in a
protected function. This is decision DD8, which the design records as settled.

The suppression goes where LLVM already decides tail-call eligibility rather
than into a check of its own. isInTailCallPosition in CodeGen/Analysis.cpp is
the target-independent answer to that question, and everything that forms a
tail call out of a call in the IR reaches it: SelectionDAGBuilder through
canTailCall, FastISel, and GlobalISel's CallLowering. Those are the same three
places that each honor "disable-tail-calls" separately, which is what asking
once here avoids. Folding a memcpy, memmove or memset into a tail call to the
library routine goes through it too, and replaces the frame just as thoroughly.

A libcall the legalizer generates has no call in the IR behind it and never
reaches that function. It is asked separately, by the SDNode overload of
TargetLowering::isInTailCallPosition, which carries its own "disable-tail-calls"
check for the same reason, and the second half of the change sits next to it.
The path is not hypothetical: an frem in return position becomes a tail call to
fmod on both x86 and ARM, and was the one remaining way a protected function
still jumped away from its frame.

musttail is diagnosed rather than suppressed. Declining an ordinary tail call
is available because forming one is an optimization; musttail is a requirement
the caller is not allowed to drop. A function that must be replaced at the call
and must clear its frame after it is a function that cannot be generated, so
the combination is rejected instead of being honored in one direction without
saying so.

The rejection is in the Verifier, in verifyMustTailCall, next to "cannot use
musttail call with inline asm". That neighbor has the same shape: not malformed
IR, but musttail combined with something that makes it impossible to honor, and
the Verifier is where that shape already lives. It is also the layer at which
the conflict is fully visible without a target. Leaving it to CodeGen would
surface as the backend's existing "failed to perform tail call elimination on a
call site marked musttail", which is fatal but never names the attribute that
caused it, and which is reached per target and twice over for a call FastISel
starts and SelectionDAG finishes. The frontend diagnostic for the same conflict
written in source is separate work.

Rejecting the combination in the Verifier makes it a bug for a pass to build
one, and one pass did. MergeFunctions rewrites a merged function into a thunk
that tail-calls the body, copies the attributes of the function it replaces
onto that thunk, and uses musttail when both functions are swifttailcc, so two
identical protected swifttailcc functions became a thunk carrying
"zeroize-stack" around a musttail call, aborting the compilation with "Broken
module found" from valid input. Protected functions are excluded from merging,
which is the answer inlining already gives them: a thunk standing in for a
protected function undertakes to clear a frame that no longer holds anything,
and where the convention makes its call musttail it cannot discharge the
undertaking at all.

tailcc and -tailcallopt are covered as well. Both exist to guarantee the
optimization rather than to permit it, and the guarantee is over the frame the
attribute is about, so a protected function does not obtain it by choosing the
convention. The cost is real: a protected tailcc function doing unbounded
mutual recursion now grows the stack. Whether that should be rejected the way
musttail is, rather than quietly losing the guarantee, is left to
trailofbits/vspells-ct-internal-notes#22 rather than settled here.

Where this meets the per-exit register clearing is worth stating, because the
two can look like they overlap. Clearing at a tail-call exit spares the
registers the callee is about to read as outgoing arguments, and a protected
function no longer has a tail-call exit: the exit classification for one now
reports both exits as returns where it used to report a tail call and a return.
That path is unreachable for a protected function. It is not dead. Register
clearing is driven by "zero-call-used-regs", a separate attribute that long
predates this work and that functions carry without "zeroize-stack"; those
functions still tail-call, and the per-exit set is still what makes their
tail-call exits correct. The test covering that case carries only
"zero-call-used-regs" and is untouched here. The two mechanisms answer for
disjoint sets of functions rather than for the same one twice.

No existing test changes, in CodeGen/X86, CodeGen/ARM, or anywhere under
Transforms. Nothing in the tree combines "zeroize-stack" with a tail call,
which is why the suppression arrives without an old test starting to expect
less. The new tests are the contrast in both directions on both targets: a
protected function that would otherwise jump does not, an unprotected one with
the same body still does, and the same pair for a legalizer libcall; the exit
classification changing from a tail call to a return; musttail in a protected
function rejected under two modes with an unprotected musttail untouched; and
the merged pair left unmerged. Each was confirmed load-bearing by breaking the
implementation once and restoring it: dropping either half of the suppression
failed the CodeGen tests at the corresponding check, dropping the Verifier
check failed the musttail test, and dropping the merging exclusion failed the
MergeFunc test.

This is trailofbits/vspells-ct-internal-notes#22, under the umbrella
trailofbits/vspells-ct-internal-notes#17.
Every narrowing the clearing machinery does is an optimization over a guarantee:
the guarantee is that nothing the function held survives it, and the narrowings
exist so that discharging it costs less. A narrowing has to be able to say why
each thing it drops is safe to drop. Where it cannot, the answer has to be to
clear more, never less, because the two errors are not symmetric: clearing
something that did not need it costs instructions, and clearing nothing that
did costs the thing the attribute exists to protect. This is the same
one-directional rule "zeroize-stack"="sensitive" already follows, where losing
metadata widens the cleared set.

Three places in the code as it stands resolved an incomplete analysis the wrong
way, or resolved it not at all. Each is fixed here, and each is grounded in a
path through the code rather than in a hypothetical.

The one that leaked is the register set. A "used" mode of "zero-call-used-regs"
clears the registers the function touched, and the sweep that computes them
skipped implicit operands. An implicit operand is how the machine layer records
a register an instruction touches without naming it, so the effect was that
every such register was treated as untouched and left alone. Inline assembly is
the worst case, because every register an asm block names -- its clobber list
and its physical-register outputs alike -- arrives as an implicit operand of the
INLINEASM instruction. A function whose register traffic was an asm block
therefore cleared nothing at all under "used-gpr", and the asm's registers
carried their contents past the return. It is not only inline assembly: rdtsc
leaves a counter in %eax and %edx and names neither, and any pseudo that
defines a register on the side reads the same way. Implicit operands now count.
This widens what a "used" mode clears; it does not collapse it into "all", and
a function that touches no call-used register still clears none.

The second is the mode itself. The switch over the value of "zero-call-used-regs"
had no default, so a value that is not one of the nine names ran off the end of
a StringSwitch: an assertion where builds have them, and in a release compiler
an uninitialized mode deciding what gets cleared. An unrecognized mode now means
"all", the widest. The modes are a scale, an unknown name says nothing about
where on it the producer meant to be, and "all" is the only reading that cannot
clear less than was asked for. It is also the reading LangRef already fixes for
an unrecognized "zeroize-stack" mode, so the two attributes now agree. "skip" is
untouched: it is a name on the scale, not a failure to read one.

The third is the exit classification. A block with no successors whose last
instruction matched none of the recognized shapes was reported as unreachable,
which is out of scope -- that is, the classification answered "control stops
here" whenever it did not recognize what it was looking at. Not recognizing an
instruction is not the same as knowing what it does. Inline assembly can jump,
can issue a system call that does not come back, and can return into another
frame, and nothing here can establish that it does not. Those blocks are now a
kind of their own, Unknown, which is in scope, and the sequence is emitted in
front of the opaque instruction. Unreachable keeps the cases that can be settled
positively rather than merely not ruled out: a block with no instructions left
in it, and an instruction the target has marked as a trap. A call that does not
return keeps its own kind and its own reason, which is about an abandoned frame
rather than about not knowing.

At an Unknown exit the sequence spares the registers the opaque instruction
declares, the same as at every other exit, because a sequence that breaks the
instruction the exit leaves through is not an option. What it clears is what the
rest of the function used, which is what would otherwise leave with the frame.
The two halves fit together: those same asm-declared registers are now cleared
at the function's ordinary returns, by the first change above.

One more path is made fail-closed without being reachable today. An in-scope
exit that the sequence could not be placed at was skipped with an assertion,
which in a release compiler is a silent skip: a function that reports itself
protected, leaves through a point at which nothing ran, and says nothing about
it. It now diagnoses instead. No IR reaches it -- every in-scope exit has an
insertion point by construction -- so it carries no test; it is here so that a
later change which introduces one is stopped rather than absorbed.

Three cases were checked and found already conservative, and are left alone
rather than given redundant code. A target that cannot clear registers or the
frame is asked before anything is computed and its refusal is reported as an
error, so an unimplemented capability fails closed. An unrecognized
"zeroize-stack" mode is fixed by LangRef as "used", the widest, and no code
consumes the mode yet, so there is nothing to make conservative. A protected
function's tail calls are suppressed and musttail rejected, which is the same
rule applied to an exit that cannot be cleared at all.

No existing test changes. CodeGen/X86, CodeGen/ARM and the whole of test/CodeGen
pass unchanged, as do the tests this stack has added. The new tests pin the
direction in each case: an asm clobber and an rdtsc cleared under "used-gpr"
where they were not, against a function that touches nothing and still clears
nothing; an unreadable mode and an empty one producing what "all" produces,
against "used-gpr" and "skip" still meaning what they say; and an asm-terminated
block reported in scope and cleared in front of, against a trap and an empty
block still reported out of scope and left alone. The classification and the
mode fallback are pinned on ARM as well, where they are decided before the
target is consulted, and where the widened mode reaches that target's refusal
rather than resolving quietly to clearing nothing. Each test was confirmed
load-bearing by breaking the implementation once and restoring it: restoring the
implicit-operand skip failed the register test, defaulting the mode to "skip"
failed the mode test on both targets, and making Unknown out of scope failed the
exit tests on both targets.

"zero-call-used-regs" has no LangRef entry to record the mode rule in; adding
one is separate work.

This is trailofbits/vspells-ct-internal-notes#24, under the umbrella
trailofbits/vspells-ct-internal-notes#17.
The clearing sequence runs the stack clear in front of the register clear
because the stack clear cannot do its work without registers: it reads the frame
through one and writes zeroes back through another, so when it finishes, the
registers it worked through hold what it has just destroyed -- the value it
overwrote, or the address inside the frame it overwrote it at. Leaving with
those in registers discloses exactly what leaving with them on the stack would
have. That order is already fixed. This is the coverage, which the order does
not give on its own.

What the register clear clears is chosen by "zero-call-used-regs", and every
mode of it is a statement about the function: which registers the function used,
which of them are argument registers, which are general purpose. A register the
clearing machinery itself dirtied is none of those things. A "used" mode does
not select it, because the sweep that computes the used set runs while the plan
is made, before the stack clear has been emitted, and so cannot see it. An "arg"
mode does not select it unless it happens to be an argument register. And a
function that asked for its frame to be cleared need not have asked for its
registers to be cleared at all, in which case there is no mode to select
anything.

So the coverage is declared rather than inferred. A step of the sequence records
the registers it used, and the register clear adds what has been recorded to
what it was already going to clear. Three things follow, and each is the point
rather than a detail of how it is written.

The declaration is per exit. A step is emitted once at each in-scope exit and
need not use the same registers at each one, so the record is built as the
sequence runs at an exit and read by the register clear at that same exit,
rather than being settled for the function the way the plan is.

The declarations are folded in after the exit has narrowed the candidate set and
not before. That narrowing exists to spare what the exit still needs, and it
would take a declared register straight back out again: a declared register is
not one the function used, it is one the sequence dirtied on the way here.

The register clear stops being optional once a step in front of it declares
anything. A function with "zeroize-stack" and no "zero-call-used-regs" gets a
register clear anyway, over nothing but the declared registers, because a
request to clear the frame is not discharged while the frame's contents are
sitting in registers. The same holds for a function that wrote
"zero-call-used-regs"="skip", which declines a clear of what the function left
in its registers and says nothing about what clearing its frame put there. It
follows that a target that cannot clear registers cannot clear the frame either,
and it now says so rather than emitting the half of the sequence it can do.

A step may only declare a register whose value at the exit nothing depends on.
That rules out the registers the exit itself names -- the return value, a tail
call's outgoing arguments, the exception object an unwind resume is passed --
and it rules out the callee-saved registers, which have to reach the exit
holding what the caller left in them whether or not the exit names them. A step
that needs such a register has to save and restore it rather than declare it,
because what is declared is cleared. Builds with assertions check both halves; a
build without them clears what it was told to, which is the direction the rest
of this machinery errs in.

What is not here is stack clearing itself, which is
trailofbits/vspells-ct-internal-notes#26 and which no target implements. The
step that would declare registers is a placeholder: it emits nothing, uses
nothing, and so declares nothing, and with no producer there is nothing to
exercise the consumer with. A hidden option, -pei-stack-clear-scratch-regs,
stands in for one. It makes the placeholder behave as a target that clears the
frame using the registers it names, declaring them and emitting nothing else,
which is the part of a real implementation the rest of this file has to cope
with. It is inert unless a test asks for it, and it is the only thing that can
reach this code today.

That fixes what the tests can honestly show, and it is one thing: a register
declared by a step in front of the register clear is cleared by it, in cases
where nothing else would have cleared it. On X86 that is %r11 cleared under
"used-gpr", which does not select it because the function does not use it; under
no register attribute at all; under "skip"; and at each of a function's two
returns rather than at one. The control is a function whose frame is not being
cleared, where %r11 is left alone, so that the other cases could not pass on
some unrelated reason for clearing it. The sequence printer reports the declared
registers at each exit, so the declaration is visible without reading it back
out of the emitted code. On ARM, which implements neither capability, a function
that asks only for its frame to be cleared is refused for the register clear it
did not ask for and needs, while a function that did ask for one is still
refused on its own terms. The registers a real stack clear would pick, and the
code that picks them, are not tested here, because they do not exist yet.

No existing test changes. CodeGen/X86 and CodeGen/ARM pass unchanged, as do the
tests this stack has added. Each new test was confirmed load-bearing by breaking
the implementation once and restoring it: dropping the declared registers from
what the register clear clears failed the X86 test, and leaving the register
clear off in a function that did not ask for one failed both. The assertion
cannot run in a build without assertions, so its predicate was checked by
turning it into a hard error for one build: declaring a callee-saved register or
the return-value register was caught, and declaring a register that is dead at
the exit was not.

This is trailofbits/vspells-ct-internal-notes#20, under the umbrella
trailofbits/vspells-ct-internal-notes#17.
A register clear is an instruction whose only effect is to overwrite a register
nothing reads afterwards, which is the definition of a dead instruction. It is
emitted precisely because nothing reads the register: the value being destroyed
is one the function is finished with, and if anything still needed it the clear
would be wrong. So the machine-level reason to keep it is missing from the
instruction itself, and it survives for as long as nothing looks. This is the
machine-level counterpart of the non-removability llvm.zeroize already carries
in the IR, and it is a separate problem, because an intrinsic's properties stop
meaning anything once it has been lowered to an XOR.

What supplies the missing reason is the exit. The clears run in front of the
instruction control leaves through, and recording the cleared registers as
implicit uses of a return says what is in fact true: their values at the point
of leaving are part of what the function leaves behind, so an instruction that
sets them has an effect that outlives the function. A pass that removes an
instruction because its definitions are dead then finds them live and leaves
the clear alone.

Only a return carries the record. The other in-scope exits leave through a call
or through an instruction the classification could not read, and neither is a
place to state what the function leaves behind: a call's register operands
describe its arguments and an opaque instruction's describe whatever the target
or the asm string put there. The clears at those exits are left as they are,
which is what they are today at every exit, and the test pins that gap rather
than passing over it.

What is recorded is what the clear wrote, not what it was asked to write. The
request names every allocatable alias of a register, so x86 is asked for %rax,
%eax, %ax, %al and %ah and emits one instruction defining %eax; reading the
emitted instructions back is what gives the registers that exist. A clear also
writes registers on the side that are not part of what it destroys -- an XOR
sets the flags -- and those are excluded, because saying the function leaves
them behind would be saying something else and something untrue.

The order matters and is the one hazard here. The record is attached strictly
after the whole sequence has run at an exit, and so strictly after the register
clear has decided what it covers there. What it attaches sits between the
insertion point and the end of the block, which is exactly the range
computeRegsToClearAtExit reads to find the registers the exit still needs.
Attach first and the register clear spares precisely the registers it was about
to clear, and spares them silently: the function reports itself protected and
clears nothing. That is not a hypothetical -- it was tried, and every clear in
the function disappeared.

A register operand on an exit is not read only as liveness, which is why the
target gets a say in which register is named. On X86 the collision is real:
X86InsertVZeroUpper treats a return that names a YMM or ZMM register as a
return carrying a vector value and skips the vzeroupper it would otherwise put
in front of it, so naming a cleared YMM there drops the vzeroupper from every
AVX function that clears its registers and charges the caller an AVX-to-SSE
transition. CodeGen/X86/zero-call-used-regs-simd.ll catches it immediately.
Which registers a target reads off an exit, and for what, is not something a
target-independent pass can know, so it asks:
TargetFrameLowering::getClearedRegExitAnchor. The default is the register
itself. X86 answers a YMM or ZMM with its 128-bit part, which keeps the
definition live -- a definition is live as soon as any part of it is -- and
says nothing about vector state.

isBarrier was considered and is not the mechanism. It says that control does not
fall through the instruction: Target.td spells the field "Can control flow fall
through this instruction?" and MCInstrDesc documents it as whether execution
stops there. It is a statement about the CFG, it is what separates an
unconditional branch from a conditional one, it has nothing to say about whether
an instruction's effects may be discarded, it would be false of a clear, and
setting it would misdescribe the block to everything that reads it.

An extra instruction was tried in place of the operands and does not work.
FAKE_USE is the pseudo for exactly this -- "represents a use of the operand but
generates no code" -- it is explicitly exempted from
MachineInstr::wouldBeTriviallyDead, and it would cover every exit rather than
only the returns. But it is not free of output: the asm printer writes a
"# fake_use:" comment for it, so every function that clears registers grows a
line and a dozen existing tests that read the emitted code line by line have to
be rewritten. Recording the fact on an instruction that is already there costs
nothing.

How much this closes is worth stating exactly, because it is less than it
sounds. DeadMachineInstructionElim is the pass that removes instructions on
these grounds, and nothing runs it after prologue/epilogue insertion. Both of
its occurrences are in addMachineSSAOptimization, before register allocation,
in the legacy pipeline and in CodeGenPassBuilder alike. What does run after,
checked with -debug-pass=Structure on X86 and ARM at -O2 and at -O0, is
MachineLateInstrsCleanup, BranchFolder, TailDuplicate, MachineCopyPropagation,
the post-RA expansions and schedulers, block placement, the target's pre-emit
passes and the layout and metadata passes, and none of them removes an
instruction for having no live definitions. MachineLateInstrsCleanup comes
closest and declines an XOR before reaching the question, because its candidate
test rejects an instruction reading a register other than the frame register.

So this is not a fix for something the compiler does today. It is the property
being made to hold in the code rather than left to the pipeline's composition,
and the difference is visible rather than argued: run
DeadMachineInstructionElim over what this pass emits and, before this change,
every clear in the function goes.

That is what the test does. It stops after prologue/epilogue insertion, pipes
the result back through llc with -run-pass=dead-mi-elimination, and checks the
clears are still there afterwards, at a single return and at each of two. The
control is in the same file: the clear at an unwind-resume exit is not anchored,
and it is gone after the pass runs, which is what the clears at the returns
would do if the record were doing nothing. A fourth function pins the target's
substitution, checking both that the return names the 128-bit register and that
the function's vzeroupper is still emitted.

No existing test changes; CodeGen/X86 and CodeGen/ARM pass unchanged, as do the
tests this stack has added. The test was confirmed load-bearing by breaking the
implementation three times and restoring it: dropping the record failed it;
dropping X86's substitution failed it and took
CodeGen/X86/zero-call-used-regs-simd.ll with it; and attaching the record before
the clear set is computed failed it along with
CodeGen/X86/zero-call-used-regs.ll, with the emitted code losing every clear it
had. Nothing here is pinned on ARM, which implements no register clearing and
so has no clears to keep.

This is trailofbits/vspells-ct-internal-notes#66, under the umbrella
trailofbits/vspells-ct-internal-notes#17.
@claude
claude Bot force-pushed the zeroize-scratch-regs branch from 9c4ac60 to 3e2a3e5 Compare August 13, 2026 13:05
@claude
claude Bot force-pushed the zeroize-machine-dce branch from c7ec7aa to 0cb5632 Compare August 13, 2026 13:05
@claude

claude Bot commented Aug 13, 2026

Copy link
Copy Markdown
Author

A full check-llvm run at the tip of the stack, with AArch64 and RISC-V added to the build, attributes three failures to this branch. Two are regressions in the already-shipped zero-call-used-regs feature; the third is this PR's own new test.

llvm/test/CodeGen/AArch64/zero-call-used-regs.ll and zero-call-used-regs-no-neon.ll both pass at PR #13 and at the upstream base 5bff54f44e, and fail here. The -mattr=-neon case is a placement change rather than a reordering: the clearing sequence is emitted ahead of the function's own first instruction (fmov d0, xzr before add w0, w0, #1).

llvm/test/CodeGen/X86/zeroize-machine-dce.ll, added by this PR, has never passed. Under -mattr=+avx the pass emits $xmm0 = V_SET0 where the test expects $ymm0 = AVX_SET0, so the upper 128 bits of the vector register keep whatever they held before. That is a gap in the clear, not a stale expectation: a register-clearing pass that emits a clear, reports success, and leaves half the register populated. Nothing downstream reports the partial coverage.

This bears on the earlier recommendation to drop this PR rather than rework it. The prior argument was cost/benefit, that no DeadMachineInstructionElim runs after PEI so the patch fixes nothing the compiler currently does, while adding a permanent TargetFrameLowering virtual and changing emitted MIR for every existing zero-call-used-regs user on three targets. The AVX result adds a correctness argument on top of that.

Build: LLVM_TARGETS_TO_BUILD="X86;ARM;AArch64;RISCV", Release, assertions off. Totals at the tip of the stack were 54950 passed / 5 failed, with the gtest unit suite clean at 11707 / 0.


Generated by Claude Code

@claude
claude Bot force-pushed the zeroize-scratch-regs branch from 3e2a3e5 to 3405cff Compare August 13, 2026 14:31
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.

2 participants