← board

A Pascal cdecl program emits no data symbols either

Split out of [[bug-a-an-object-neither-exports-nor-imports-data-symbols-and-links-silently-wrong]] when its C half landed (72000d1e1). That ticket's acceptance said "both frontends, since Pascal cdecl units have the same exposure". Only the C frontend is done. Filed rather than left implied, because the C half's symbol tables now look complete and a reader could reasonably assume the Pascal ones do too.

Measured, by Track B, independently of the C work

A Pascal program with cdecl exports and string/array globals builds and runs correctly through --emit-obj:

program pg2;
var GName: AnsiString = 'global-string';
    GTable: array[0..2] of AnsiString = ('a', 'bb', 'ccc');
function pick(i: Integer): PChar; cdecl;
begin
  GName := GName + '!';
  pick := PChar(GName + '/' + GTable[i]);
end;
begin
end.

A C main calling pick(0..2) prints global-string!/a, global-string!!/bb, global-string!!!/ccc — so the globals are initialised, mutated and survive across calls into the object.

And nm --defined-only pg2.o | grep -cE " [BbDd] " is 0. Every symbol is t. The globals are real and live in .bss; nothing names them to the linker. That is the same defect the C half fixed, in the other frontend.

Two shape constraints found while constructing it, worth keeping: an exports clause is rejected by the parser ("expected 'begin' before 'exports'"), and --emit-obj on a unit errors ("this file is a unit, not a program"). So the carrier has to be a program defining cdecl routines.

Why it is not a one-line extension of the C half

The writer is ready: ObjPlanHostedSymbols walks three data groups and both writers emit them. What is missing is MEMBERSHIP. SymCFileScope is set in ParseCGlobalVarDecl and nowhere else, so a Pascal global is never a candidate and the whole mechanism is inert for it.

The obvious extension — "mark every skGlobal with a name" — is the failure mode, and the C half already walked into it. crtl is compiled as C and bundled into every object, so its file-scope variables ARE C file-scope variables; the first version of the C half exported errno, environ, optarg, optind, opterr, optopt and optreset, and the link failed outright because glibc's errno is TLS in .tbss and ours is not. The Pascal side has the same shape and a far larger surface: every RTL unit's globals would join.

So this needs a Pascal answer to "whose declaration is this". PasMarkTokFile exists and is the likely instrument — the C half uses CModuleOfTok plus the interned module PATH, and deliberately not the module ID (see CDeclIsFromCrtl for why: under --emit-obj the user's own file has a real module range, so "id < 0 means the main source" is false there).

Acceptance

RESOLVED — d402147d6, and the acceptance above was changed, not just met

Landed as cvar / public on a Pascal global. Read the divergence from this ticket's own acceptance before reusing it:

This ticket asked for "a cdecl program's own var block globals appear as OBJECT GLOBAL". That is not what shipped, and it should not have been. The defect was never a missing export pass — it was a missing SPELLING. var x: Integer; cvar; answered "expected ':' before ';'": the directive follows the semicolon that ENDS the declaration, so it arrived looking like the start of the next one. public and external were the same. With no way to say it, the count could only be 0.

Exporting every global instead would contradict the writer's own stated rule, one screen above the code this touched: only cdecl routines export, "so an object cannot collide with its host over an RTL name". var i: Integer in two pxx objects, or against a host's i, is a duplicate definition the link refuses — and this ticket's own third acceptance row asks for exactly that collision, which is an argument against exporting things nobody nominated.

The asymmetry with the C half is the two LANGUAGES, not an inconsistency. C 6.9.2 gives a file-scope variable external linkage as a language rule, so exporting it is reading C. Pascal gives a var block no linkage concept, so there is nothing to read and the compiler must be told.

My first cut was the blanket version, gated on "declared in the file the user named". Wrong in both directions: a unit of a multi-unit program that writes cvar means it, and a program's unmarked globals are not an interface just because the user typed that filename. The provenance field it needed (MainSrcPath) was removed with it rather than left unread.

Measured

Fixture declares four globals, marks two, and uses sysutils (which brings ~24 more, plus ~23 from the builtin runtime).

Left open, deliberately, each with a ticket

Log

Correction to the repro quoted above — it reads freed memory

The pg2 program in "Measured, by Track B" returns PChar(GName + '/' + GTable[i]), and the write-up reads its correct-looking output as evidence that "the globals are initialised, mutated and survive across calls". The output is correct; the reasoning is not, and the symbol-table finding the ticket was filed on does not depend on it.

Re-measured 2026-09-01 at a4c67a5e6cc8, same source, same C main, one flag added:

$ ./compiler/pascal26 -Fulib/rtl --emit-obj pg2.pas pg2.o && gcc main.c pg2.o -o pg2 && ./pg2
global-string!/a
global-string!!/bb
global-string!!!/ccc

$ ./compiler/pascal26 -Fulib/rtl -dPXX_HEAP_DEBUG --emit-obj pg2.pas pg2d.o && ...
<24 bytes of 0xDD>   (x3)

The concatenation is a TEMPORARY and the PChar points into it, so it dies at return under any ownership model — the first run prints correctly only because nothing has reused the block yet.

Bisected (frankB, 2026-09-01): 7cd695c7d, "give PChar(computedString) an owner at the cast". Before it the temporary leaked and the pointer stayed readable by luck; after it the temporary is owned and released at scope exit, so the same read lands in freed memory and -dPXX_HEAP_DEBUG paints it 0xDD. Nothing regressed — the fix made an existing latent bug visible, which is what the poison byte is for.

And it is not a divergence. FPC compiles the same shape and prints garbage too:

$ fpc -O2 -gh pg2fpc.pas && ./pg2fpc | od -An -tx1 | head -2
 f0 f0 f0 f0 f0 f0 f0 f0 f0 f0 f0 f0 f0 f0 f0 f0
 f0 ef be ad de ef be ad de 0a
...
0 unfreed memory blocks : 0

ef be ad de is 0xDEADBEEF little-endian, twice — heaptrc's freed-block signature, the exact counterpart of pxx's 0xDD. Both compilers free the temporary, both then read it, and FPC reports 0 unfreed blocks while printing rubbish. So this is not "pxx frees where FPC keeps"; the program is undefined in both, and there is no compat question left to answer.

The claim was fine — the WITNESS was broken

The original write-up's claim ("the globals are initialised, mutated and survive across calls") is TRUE. GName does persist and is mutated. The fixture just proved it the one way that proves nothing: through a pointer into a temporary. The witness that actually holds is the global itself, with no concatenation:

function pick3: PChar; cdecl;
begin
  GName := GName + '!';
  pick3 := PChar(GName);          { the GLOBAL itself — no temporary }
end;
$ pascal26 -Fulib/rtl -dPXX_HEAP_DEBUG --emit-obj pg3.pas pg3.o && gcc main3.c pg3.o -o pg3 && ./pg3
global-string!
global-string!!
global-string!!!

Correct under the poison flag, and shorter than the original. The symbol-table finding this ticket was filed on never depended on the broken fixture either way.

Keep the shape out of interop examples. The rule is sharper than "don't return a temporary": a cdecl routine returning PChar must return a pointer into storage the caller can name. Any expression at all inside PChar() makes it a temporary — it worked for exactly as long as it leaked.