Files

13 KiB
Raw Permalink Blame History

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 standard RootWindow/DefaultVisual/ DefaultGC/BlackPixel macros. It touches Display->screens, Screen->root_visual/root/default_gc, XImage->data and XImage->f.put_pixel, XEvent.xkey.display, XTextProperty.value, and XClassHint.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-table switch, 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 to base + 8*(byte offset), including constant-expression GEPs. It leaves loads/stores, alloca, memcpy and lifetime alone.
  • xlib_min.h — minimal Xlib declarations (32-bit long and 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 -O2 before scaling, clang folds int_diff to 1 and int_diff4 to 4; the GEP is already gone, so a late pass cannot fix it.
  • At -O0 then bitscale then -O2, they become 8 and 32 — correct.
  • addr_of_next becomes base + 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:

  1. a small clang CodeGen change that scales GEPs and emits the pointer difference divisor as sizeof(T) * 8 (the clean solution), or
  2. a frontend-generated marker/metadata on pointer-difference subs so a later IR pass can distinguish them from explicit integer casts, or
  3. a documented dialect restriction that (int)(p+n) - (int)p is 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; CALLA pushes the return address on A15; RETS 2 pops 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=32 after every external call. FS0 may 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:

  1. 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.
  2. The dual-stack / FS1 TI ABI. Custom call, return and frame lowering; more work than the pointer model.
  3. The backend itself — instruction selection, register allocation, asm printer/relocations to as34/GSPA, memcpy/byval/varargs, and a C library (reuse rts.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, .bss in bits).
  • dwt/gui/xhello.asm (A8/A13/A14, CALLA, FS0=16/FS1=32 after each call).
  • LLVM 19.1.7 llvm/include/llvm/IR/DataLayout.h (getTypeAllocSize returns bytes; getIndexedOffsetInType is byte-based) and clang/lib/CodeGen/CGExpr.cpp (GEP and pointer-difference emission).