13 KiB
Bit-addressed pointers in LLVM — throwaway spike
Status: spike only. Nothing here is a compiler, and nothing was run on a TMS34010. The purpose was to measure one question: if a TMS34010 target keeps TI's pointer representation (a pointer is a 32-bit bit address), how much of LLVM has to change, and where does it hurt?
Measured 2026-11-10 against LLVM 19.1.7 on x86-64. The LLVM source is checked
out in vendor/llvm-project at tag llvmorg-19.1.7 (commit cd70802), sparse
(134 MB); the host toolchain is Debian's clang-19/llvm-19-dev.
The question, restated
TI's C ABI does not use byte addresses. From the 1988 TMS34010 C Compiler Reference Guide:
All objects except structure members and array members are aligned on a 16-bit (one-word) boundary. ... Structure or array members are not aligned to 16-bit boundaries. (§4.3)
and the generated code in harness/c-hello/build/hello.s shows the arithmetic:
a char step is SLL 3 (×8), a 32-byte array row is SLL 8 (×256), and
.bss STATIC_3,18432,16 is 18432 bits for 2304 bytes.
LLVM's IR and DataLayout define the address unit as the 8-bit byte:
getelementptr i8, ptr %p, i64 1 advances one unit. There is no DataLayout
knob for "one bit per address unit" while keeping CHAR_BIT == 8.
What was built
xclient.c— a small but pointer-heavy Xlib client:XOpenDisplay,XCreateWindow,XCreateImage,XPutImage,XNextEvent,XLookupString,XGetWMName/XmbTextPropertyToTextList,XSetClassHint,XSetWMProperties, plus the standardRootWindow/DefaultVisual/DefaultGC/BlackPixelmacros. It touchesDisplay->screens,Screen->root_visual/root/default_gc,XImage->dataandXImage->f.put_pixel,XEvent.xkey.display,XTextProperty.value, andXClassHint.res_name/res_class.stress.c— the IR shapes the client does not have: a constant-expression GEP ("hello" + 2), a by-value struct argument, varargs, a VLA, a jump-tableswitch, and a 64-bit divide.ptrdiff.c— pointer arithmetic edge cases.layout.c— TI's packed struct layout.bitscale.cpp— a standalone LLVM IR tool that rewrites every non-constant GEP tobase + 8*(byte offset), including constant-expression GEPs. It leaves loads/stores,alloca,memcpyandlifetimealone.xlib_min.h— minimal Xlib declarations (32-bitlongand pointers, packed structs where appropriate).
Build and run: ./run.sh (or see the commands at the end).
Result 1 — the GEP transform is small and works
bitscale is about 30 lines of real logic:
// for every getelementptr: base_int + (byte_offset << 3)
Value *BaseInt = B.CreatePtrToInt(Base, i32);
Value *BitOff = B.CreateShl(ByteOffset, 3);
Value *NewPtr = B.CreateIntToPtr(B.CreateAdd(BaseInt, BitOff), ptr);
On xclient.c:
| IR | GEPs | allocas | memintrinsics | loads/stores | ptr subtractions |
|---|---|---|---|---|---|
xclient.O0.ll |
61 | 35 | 1 | 155 / 54 | 0 |
xclient.O2.ll |
35 | 10 | 3 | 49 / 24 | 0 |
xclient.packed.O2.ll |
35 | 10 | 3 | 49 / 24 | 0 |
after bitscale |
0 | 10 | 3 | 49 / 24 | 0 |
opt -passes=verify accepts the result. The Xlib object chain survives as
expected. For DefaultScreen/RootWindow/DefaultVisual, the IR becomes:
%bit.base = ptrtoint ptr %dpy to i32
%bit.screens = add i32 %bit.base, 1056 ; Display->screens at byte 132, ×8
%p = inttoptr i32 %bit.screens to ptr
%scr = load ptr, ptr %p
%bit.base2 = ptrtoint ptr %scr to i32
%off = mul i32 %scr_index, 640 ; sizeof(Screen)=80 bytes, ×8
%rootv = add i32 %bit.base2, %off
... + 320 for Screen->root_visual (byte 40), +64 for root (byte 8), ...
Those constants are exactly TI's bit addresses: Display->screens is byte 132
→ bit 1056; Screen is 80 bytes → 640 bits; root_visual is byte 40 → 320
bits. instcombine at -O2 does not reintroduce GEPs in this corpus, and
the scaled constants survive.
Result 2 — the optimizer order matters, and pointer subtraction is the trap
The GEP transform must run before clang's optimizer, not after. ptrdiff.c
shows why. The practical sequence is clang -O0 -emit-llvm (with
-Xclang -disable-O0-optnone) → bitscale → opt -O2, or a clang pass plugin
at an early extension point. xclient.c also survives the late order because
it has no pointer casts or subtractions, but that is not a general guarantee.
int int_diff(char *p) { return (int)(p + 1) - (int)p; }
int int_diff4(int *p) { return (int)(p + 1) - (int)p; }
long ptr_diff(char *a, char *b) { return a - b; }
- At
-O2before scaling, clang foldsint_diffto1andint_diff4to4; the GEP is already gone, so a late pass cannot fix it. - At
-O0thenbitscalethen-O2, they become 8 and 32 — correct. addr_of_nextbecomesbase + 8— correct.
That is the good news. The bad news is pointer subtraction:
; ptr_diff(char *a, char *b), after bitscale + -O2
%a = ptrtoint ptr %0 to i32
%b = ptrtoint ptr %1 to i32
%s = sub i32 %a, %b
ret i32 %s ; bits, but C wants bytes
; pdiff_int(int *a, int *b), after bitscale + -O2
%s = sub i32 %a, %b
%d = ashr exact i32 %s, 2 ; clang's /4; TI needs /32
bitscale cannot repair these, and a pure IR pass cannot reliably tell them
apart from an integer cast:
(int)(p + 1) - (int)p // same IR shape: sub(ptrtoint, ptrtoint)
p - q // C pointer difference: must be divided by 8
There is also the constant-expression case: stride() in layout.c is
(char *)&arr[1] - (char *)&arr[0]; clang folds it to 9 at -O2 (correct),
but if you scale the constant GEP first it becomes 72, and only a
frontend-aware pointer-difference rewrite can turn it back into 9.
Consequence: a GEP-scaling pass is enough for a client like xclient.c,
which contains zero pointer subtractions. A general C compiler needs either:
- a small clang CodeGen change that scales GEPs and emits the pointer
difference divisor as
sizeof(T) * 8(the clean solution), or - a frontend-generated marker/metadata on pointer-difference
subs so a later IR pass can distinguish them from explicit integer casts, or - a documented dialect restriction that
(int)(p+n) - (int)pis undefined / unsupported (not acceptable for ordinary C).
This is the modern analogue of Andrew Morton's warning: the frontend, not just
the backend, assumes (char *)p + 1 == (long)p + 1. In LLVM the damage is
concentrated in clang/lib/CodeGen/CGExpr.cpp (GEP and pointer-difference
emission), not spread across 110 GCC 2.5.8 sites.
Result 3 — struct layout is solvable, and TI's rule is fully packed
TI packs structure members at bit granularity and aligns only the structure
itself to 16 bits. From layout.c:
sizeof(S) |
offsetof(i) |
offsetof(p) |
stride of S[2] |
|
|---|---|---|---|---|
| clang i386 default | 12 | 4 | 8 | 12 |
clang -fpack-struct=1 |
9 | 1 | 5 | 9 |
| TI guide (§4.3, §5.1.6) | 9 | 1 | 5 | 9 |
So the correct LLVM model is packed struct types (%struct.S = type <{ i8, i32, ptr }>) with scalar object alignment of 16 bits — not "make every scalar
1-byte aligned". -fpack-struct=1 reproduces it for the spike; a real target
sets the same policy in its TargetInfo/ASTContext (and must decide what
_Alignof(int) reports, because TI aligns objects, not members).
For the Xlib structs in this client (Display, Screen, XImage, XEvent,
XTextProperty, XClassHint, XSizeHints) all members are 4 bytes, so packed
and 4-byte alignments give the same offsets. User structs and X11 extensions
with a char before an int will differ.
Result 4 — the TI ABI itself is the non-standard part
The pointer model is only half of "TI-compatible". From the C guide §5.2–5.3
and dwt/gui/xhello.asm:
- Return value in A8; A13 = FP, A14 = STK (program stack), A15 = system stack (interrupt stack).
- Arguments are pushed upward through A14 in reverse order and widened to
32 bits. The caller first pushes the old A14 on A15;
CALLApushes the return address on A15;RETS 2pops the return address and old STK. Callee-saved registers are saved on A15, locals live on A14. - FS1 must be 32 inside C code; Xlib clobbers the field-size state, so the
caller must re-establish
FS1=32after every external call.FS0may be changed without restoring. - Structures are packed for layout but rounded up to a word boundary when passed by value.
This dual-stack convention is not a standard LLVM calling convention. LLVM's
LowerCall/LowerReturn and frame lowering can be customized for it, but the
backend must model A15 as a second stack (or use custom prologue/epilogue
nodes), and FS1 behaves like a call-clobbered status register. That is more
work than the pointer scaling, but it is bounded and well documented.
What remains after the pass
bitscale deliberately leaves these alone; each is a backend boundary:
| IR construct | xclient count |
what the backend must do |
|---|---|---|
llvm.memcpy of XEvent |
3 (96 bytes) | copy 96 × 8 bits |
alloca |
10 | allocate size × 8 bits, frame offsets in bits |
byval(%struct.S) |
0 here, 1 in stress.c |
copy sizeof bytes rounded to 16 bits |
llvm.va_start |
0 here, 1 in stress.c |
TI-compatible va_list if calling sprintf |
indirect call img->f.put_pixel |
1 | function pointers are bit addresses; no change |
| constant-expression GEP | 0 here, 1 in stress.c |
rewritten by bitscale |
align 4 hints |
many | ignore or lower to TI's 2-byte object alignment |
| 64-bit divide | 0 here, 1 in stress.c |
libcall or sequence (no 64-bit divide) |
Loads and stores themselves are the easy part on this CPU: MOVB moves 8 bits
at any bit address, and MOVE ...,FS1 moves 32 bits at any bit address, so a
bit-address load i8/load i32 is one instruction, not a shift/mask sequence.
Verdict
Not horrible at the pointer level. A 30-line IR pass scales every GEP in a
real Xlib client, LLVM verifies it, -O2 preserves it, and the result is
directly interoperable with Xlib's bit-addressed structs. A byte-address model
would instead need conversions at every pointer field the standard macros
traverse (screens, root_visual, XImage->data, put_pixel,
XEvent.xkey.display, XTextProperty.value, …), which is worse.
The three real costs are:
- A clang CodeGen change for pointer difference (and the GEP scaling, if you want it before clang's optimizer). A pure post-IR pass cannot do it correctly. This is a fork of clang, not just an LLVM backend.
- The dual-stack / FS1 TI ABI. Custom call, return and frame lowering; more work than the pointer model.
- The backend itself — instruction selection, register allocation, asm
printer/relocations to
as34/GSPA,memcpy/byval/varargs, and a C library (reuserts.lib/Xlib or build one). This is the usual target-port scale.
None of these is a blocker. The spike says the bit-address question is answerable with a small, well-localized change, and the remaining project is an ordinary (if unusual) LLVM target plus the TI ABI.
Reproduce
cd spike
clang++-19 -std=c++17 $(llvm-config-19 --cxxflags) bitscale.cpp \
$(llvm-config-19 --ldflags --libs core irreader support --system-libs) -o bitscale
clang-19 -target i386-unknown-none -ffreestanding -nostdinc -fno-builtin \
-fpack-struct=1 -O2 -S -emit-llvm xclient.c -o xclient.packed.O2.ll
./bitscale xclient.packed.O2.ll xclient.packed.O2.bits.ll
opt-19 -passes=verify -disable-output xclient.packed.O2.bits.ll
opt-19 -passes='default<O2>' -S xclient.packed.O2.bits.ll -o /tmp/x.ll
grep -c getelementptr /tmp/x.ll # 0
grep -o 'add i32 %bit.base[0-9]*, [0-9]*' /tmp/x.ll | head
# early scaling is required for (int)(p+1) - (int)p:
clang-19 -target i386-unknown-none -ffreestanding -nostdinc -fno-builtin \
-Xclang -disable-O0-optnone -O0 -S -emit-llvm ptrdiff.c -o ptrdiff.O0.ll
./bitscale ptrdiff.O0.ll ptrdiff.O0.bits.ll
opt-19 -passes='default<O2>' -S ptrdiff.O0.bits.ll -o /tmp/p.ll
grep -A2 'define.*@int_diff' /tmp/p.ll # ret i32 8
grep -A2 'define.*@int_diff4' /tmp/p.ll # ret i32 32
grep -A6 'define.*@ptr_diff' /tmp/p.ll # still bits: needs frontend fix
Sources
- TI, TMS34010 C Compiler Reference Guide (SPVU005A, 1988-08), §4.3, §5.1.6, §5.2.1, §5.3.
harness/c-hello/build/hello.s(TI 2.01 output:SLL 3,SLL 8,.bssin bits).dwt/gui/xhello.asm(A8/A13/A14,CALLA,FS0=16/FS1=32after each call).- LLVM 19.1.7
llvm/include/llvm/IR/DataLayout.h(getTypeAllocSizereturns bytes;getIndexedOffsetInTypeis byte-based) andclang/lib/CodeGen/CGExpr.cpp(GEP and pointer-difference emission).