LLVM's integrated Arm assembler could mark an ARM function as Thumb when its .type directive appeared after a switch to Thumb mode.

The patch is in LLVM PR #211641. It fixes issue #211376.

Why I picked it

I picked the issue because it was small and bounded. It already had a minimal reproducer, GNU as gave me a reference result, and the relevant LLVM code contained a FIXME that more or less described the fix.

This is not expected to occur in practice ...
... track IsThumb for every label.

I did not discover a clever new assembler trick here. The FIXME already identified the missing lifetime: LLVM knew the current state, but not the state in which an earlier label had been emitted. My part was verifying that the supposedly impractical case now happened in a real kernel build, following the bad bit to that code, choosing how much state to retain, and writing a test that failed in both directions.

The small reproducer

.text
.syntax unified

.arm
.global secondary_startup_arm
secondary_startup_arm:
  nop

.thumb
.global secondary_startup
secondary_startup:
  nop

.type secondary_startup, %function
.type secondary_startup_arm, %function

secondary_startup_arm is emitted while the assembler is in ARM state. By the time its .type directive is processed, the active state is Thumb.

The low bit

The Arm ELF ABI marks a Thumb function by setting bit zero of its symbol value. GNU as leaves secondary_startup_arm at 0x0. LLVM's integrated assembler produced 0x1 for the same source.

arm-linux-gnueabihf-as -mthumb test.s -o test-gas.o
clang --target=arm-linux-gnueabihf -mthumb -c test.s -o test-llvm.o

llvm-readelf -s test-gas.o
llvm-readelf -s test-llvm.o

This is not only an object-file detail. In the reported kernel failure, Linux passed that symbol address to hardware while bringing up a secondary ARM32 CPU. The original report traces the bad address to that path; an incorrect low bit selects the wrong instruction set.

Tracing the bit backwards

I started at the wrong observable value rather than trying to learn the whole MC layer first. Searching for symbolValue, isThumbFunc, and setIsThumbFunc led from the ELF writer back to ARMELFStreamer::emitSymbolAttribute().

The writer was doing what it was told: set bit zero when the assembler says a symbol is a Thumb function. The bad decision had already happened while processing .type.

The state was read too late

ARMELFStreamer handled a function type attribute by asking whether the streamer was currently in Thumb mode. That answers the state at the .type directive, not the state at the symbol's label.

Those states are usually the same, which is why the bug only appears when the directive is delayed across an .arm or .thumb switch.

Fixing the caller versus fixing the assembler

The failure is also tracked as ClangBuiltLinux issue #2177. The kernel side produced two ways to avoid this particular failure without changing LLVM. One moves ENDPROC(secondary_startup_arm) before the source switches to Thumb, so the generated .type is processed while the assembler is still in ARM state. A later MediaTek-specific patch clears bit zero before writing the startup address to the hardware jump register; its mailing-list submission is here.

Both contain the kernel failure, but neither changes what LLVM emits for the reproducer. The same valid assembly would still produce a different symbol value under LLVM and GNU as. That is why I worked on the assembler rather than adding a third caller-side workaround.

Recording label-time state

The patch records symbols emitted in Thumb state. When a later .type marks a symbol as a function, the streamer consults that recorded label state instead of its current state.

My first version stored a Boolean for every label in a DenseMap. The final version uses a DenseSet because the consumer asks only whether a label was emitted in Thumb state; absence already represents an ARM label. This also avoids storing a false entry for every ARM label.

The implementation keeps that set in the ARM ELF streamer and clears it on reset. The regression test covers both directions and checks the final symbol table through llvm-readelf. It failed in both directions before the patch. After the change, all 577 ARM MC tests passed, followed by check-llvm with no unexpected failures.