← board

A typed const array is built by startup code, not stored as data

The bug

A typed constant array does not become initialised data. It becomes a BSS reservation plus generated code, one store per element, run at program start. Cost measured at ~29 bytes of code per element for UInt64, plus the startup time to run it, in every binary — including one that never reads the array.

Not specific to 64-bit types. Same 696-element shape, same result — data never moves and the storage is uninitialised BSS:

element type code delta vs 1-element data bss delta
UInt64 +20,152 unchanged +5,560 (695 x 8)
Int64 +16,680 unchanged +5,560
Double +20,228 unchanged +5,560
Cardinal +15,986 unchanged +2,776 (694 x 4)

Repro — two programs differing only in element count

program tsz0;
const T: array[0..0] of UInt64 = ($1);
var s: UInt64; i: Integer;
begin s:=0; for i:=0 to 0 do s:=s+T[i]; writeln(s<>0); end.

versus the same with array[0..695] and 696 literals.

code data bss
1 element 54,204 1,560 9,512
696 elements 74,356 1,560 15,072
delta +20,152 0 +5,560

+5,560 bss is exactly 695 * 8, so the storage is reserved but uninitialised; data does not move at all; and +20,152 code over 695 extra elements is 29 bytes per element of fill-in-at-startup.

The data path exists — a string constant uses it

The same 5,568 bytes written as an AnsiString constant of #nn escapes:

code data
baseline 54,204 1,560
696 u64 as a string const 54,217 7,144

+13 bytes of code and +5,584 of data. So the backend can and does place constant bytes in .data; the typed-array lowering simply does not take that route.

Why it matters beyond one table

Any lookup table — CRC tables, codec tables, trig tables, the power-of-ten table that prompted this — pays 4x its own size in code and a startup pass. It also puts a real thumb on the scale against writing table-driven code at all, which is the wrong incentive: the natural, readable spelling is the one being penalised.

It is worst for the targets least able to afford it. On ESP32/xtensa a 10 KB table becomes ~40 KB of flash plus boot-time stores.

Not worked around, deliberately

lib/rtl/sysutils.pas keeps the plain const array. Re-encoding the table as a string blob to dodge this would have hidden the bug and made the table unreadable, which is exactly what CLAUDE.md's platonic-code rule forbids. The cost is recorded in that unit's header instead. When this is fixed, sysutils gets ~31 KB smaller with no change to its source — worth re-measuring then as the check that the fix landed.

Suggested check when fixing

The table above is the regression check — every one of those code deltas should collapse to roughly the element bytes moving into data. Also confirm the array lands in a read-only section where the target supports one, since nothing may write to a typed constant.


Fixed 2026-08-21

Measured, same two programs the ticket used

code data bss
1 element 58,340 1,616 42,440
696 elements 58,340 7,176 42,440
delta +0 +5,560 +0

+5,560 is exactly 695 * 8 — the table's own bytes, and nothing else. The ticket's "every one of those code deltas should collapse to roughly the element bytes moving into data" is the result, literally.

sysutils, the check the ticket asked for

uses SysUtils program, pinned (v369, pre-fix) vs HEAD:

code data bss
before 298,924 8,804 86,812
after 258,222 19,988 75,628
delta -40,702 +11,184 -11,184

-40.7 KB of code and -11.2 KB of RAM, with no edit to sysutils' source — better than the ~31 KB the ticket predicted. The unit's header note is updated to record it, because "we did not work around the compiler bug and got the win for free" is worth having written down as a measurement rather than a principle.

How: one biased offset, not a per-backend change

A global's Syms[i].Offset is a BSS offset, and ~280 call sites across six backends reach a global through EmitGlobRef(Syms[i].Offset). Teaching all of them about a second section was never the job. Instead a data-resident symbol carries DATA_SYM_BIAS + dataOffset, and the ELF writers' fixup loops decode it — three identical five-line arms, plus one in the DWARF location emitter. Nothing in any backend changed, and every target got the fix at once. Verified by running the value test on i386, arm32, aarch64 and riscv32: identical output on all four.

A bias rather than a flag or a negative sentinel because offset ARITHMETIC has to keep working: Offset + 8 for a field or an element survives a bias and would silently corrupt a sentinel.

.data is writable here (one RWX PT_LOAD) and that is required, not incidental: writable typed constants are a real Pascal feature, and the ticket's "confirm the array lands in a read-only section" would have turned a working program into a fault. The test writes to a typed const on purpose.

Fails closed, all-or-nothing per array

TryBakeConstArrayIntoData promotes only when every element is a plain ordinal or float literal covering the whole extent in order. A string element (a heap handle), a record element (a field list), a @proc, a class ref, a set — any one of them and the whole array keeps the existing startup-store path, which still works. Refused outright for --emit-obj / --shared: those writers emit RELOCATIONS against a section symbol rather than patching an absolute address, so a data-resident global needs a different section index there. Not done; refusing is what keeps that honest, and it is the one piece of this ticket left open.

The BSS the array had already been reserved is reclaimed, not left as a hole — otherwise the fix would have traded 20 KB of code for 5.5 KB of dead RAM and the half of the ticket that matters on an MCU would still be open.

The trap, found by measuring

Single. A float literal is recorded as the DOUBLE's bit pattern whatever the declared element type is, so baking array of Single by copying the low four bytes gives garbage — (0.0, -1.5, 2.5, 1e30) read back as (0, 0, 0, 3.06e-4). Caught by diffing the value test against FPC, not by reading the code: the first three arrays in that test were byte-perfect. The fix lets the HOST narrow (f := d through a Double, then read the 4 bytes back), which is the same IEEE round-to-nearest the target's cvtsd2ss would have done on the path this replaces.

Left open

Gate

test/test_const_array_in_data.pas — every baked element width, a negative low bound, the three arrays that must NOT bake, and a write to a typed const. Identical to FPC 3.2.2, and identical on i386/arm32/aarch64/riscv32. make compiler/pascal26 fixedpoint (1 round) + tools/gate.sh quick GREEN. PXXDBG=a.constdata lists what got baked (documented in devdocs/dev/debug-switches.md).

Log