← board

--emit-obj ignores external name 'sym' and emits the Pascal identifier as the undefined symbol

What happens

program alias_esp;
function PalSysRead(fd: Integer; buf: Pointer; n: Integer): Integer; cdecl; external name 'read';
function PalSysWrite(fd: Integer; buf: Pointer; n: Integer): Integer; cdecl; external name 'write';
function PalSysOpen(path: PChar; flags: Integer; mode: Integer): Integer; cdecl; external name 'open';
var n: Integer;
begin
  n := PalSysOpen(PChar('/x'), 0, 0);
  n := PalSysRead(n, nil, 0);
  n := PalSysWrite(n, nil, 0);
end.
$ pxx --target=riscv32 --platform=esp --no-signals --emit-obj alias_esp.pas alias_esp.o
ok: alias_esp.o  [code=339708B ...]
$ readelf -sW alias_esp.o | awk '$7=="UND"{print $8}'
calloc
free
PalSysOpen        <-- should be `open`
PalSysRead        <-- should be `read`
PalSysWrite       <-- should be `write`

The object asks the linker for PalSysRead, a name that exists in no C library. name 'read' was parsed and discarded.

Why it is a bug and not a missing feature

The feature exists and its semantics are already documented in compiler/pasparser_proc.inc (~1283):

name 'sym' sets the LINK symbol, NOT the Pascal routine identifier — the routine stays callable by its declared name (e.g. c_dlopen ... name 'dlopen').

And it is honoured on the dynamic-import path. The same source built as a host executable emits a dynamic import named write — it fails at run time with undefined symbol: write, which is the loader not finding libc's symbol, i.e. the alias was applied. So one back end honours the clause and the other silently drops it.

Scope: not ESP-specific

Same three UND symbols under every --emit-obj combination tried:

build UND emitted
--emit-obj (x86-64) PalSysOpen / PalSysRead / PalSysWrite
--target=riscv32 --emit-obj same
--target=riscv32 --platform=esp --emit-obj same

So it is the relocatable-object writer, not a target or platform quirk.

Failure mode — silent, and it surfaces far away

Compilation succeeds with no diagnostic. The breakage appears later, in a foreign build system, as an undefined reference to an identifier that appears nowhere in any C source — for ESP that is an ESP-IDF CMake link error naming a Pascal symbol, which reads as "the Pascal object is broken" rather than "a directive was ignored". Per CLAUDE.md's table this is the silent wrong behaviour row: source that compiles and then behaves wrongly is a bug in the owning lane, not a compat or feature item.

Why it matters beyond the one directive

read and write are Pascal keyword tokens (lexer.inc:352 -> tkRead, :369 -> tkwrite), so a routine cannot simply be named read. external name 'read' is the language's answer to exactly that, and on --emit-obj there is currently no answer at all: a C symbol whose name collides with a Pascal keyword is unreachable from a relocatable object.

That blocks [[feature-pal-esp-posix-fd-semantics]], whose whole design is moving the ESP PAL onto direct POSIX open/read/write/close. That ticket's body guesses the fix is "imported C declarations under safe Pascal names or a compiler-supported external symbol alias" — the alias exists; it is just not applied here.

The workaround is deliberately not being taken. Renaming the C side, or routing the PAL around the collision, is the compiler-appeasement pattern CLAUDE.md forbids: it would hide this and look like progress.

Fix direction (Track A's call)

Wherever the object writer materialises an undefined symbol for an external routine, use the recorded link symbol (extSym) when set, falling back to the routine identifier — the same precedence the dynamic path already applies. The parser already captures it; the question is only whether the object writer reads it.

Suggested regression

--emit-obj a program with external name 'sym' and assert with readelf -sW that the UND symbol is sym and not the Pascal identifier. Cheap, no linker needed, and it fails today.

Note the test must assert the symbol NAME. "It compiles" passes right now.


RESOLVED 2026-08-30 (frankA)

The divergence, and it was FIVE copies of one decision, not two

The ticket frames this as one back end honouring the clause and another discarding it. The measurement is narrower and worse: elfwriter.inc made the same "alias, else Pascal name" decision in five places, hand-rolled each time. Two were right and three were wrong.

site writer before
PrepareDynamicData ×2 executable dynamic imports correct — tested ProcExtName
writeELF32RelIram xtensa/riscv32 --emit-obj (iram) wrong
writeELF32Rel 32-bit --emit-obj wrong
writeELFRelX64 x86-64 --emit-obj wrong
writeELFSharedX64 --shared (.so) wrong — not in the ticket

Each wrong writer was wrong twice, because sizing the string table and writing it are separate loops: Length(Procs[ExternalProc[i]].Name) when sizing, writeStrZ(..., Procs[ExternalProc[i]].Name) when writing. Ten call sites over five decisions.

--shared was affected too, and the ticket did not know

Found by grepping for the sibling rather than by trusting the scope line. It is invisible to readelf: the .so this writer emits carries no section header table, so readelf --dyn-syms prints "Dynamic symbol information is not available" and a symbol-level check sees nothing at all. The bytes are still in .dynstr, so the demonstration is strings:

alias_old.so:  PalSysOpen 1  PalSysRead 1  PalSysWrite 1   bare 'open' 1
alias_new.so:  PalSysOpen 0  PalSysRead 0  PalSysWrite 0   bare 'open' 2

An instrument that cannot see a defect is not evidence the defect is absent.

The fix — one function, not six patches

ExternalLinkName(extIdx) at elfwriter.inc:126 is now the only place the decision exists; all ten sites call it. This is normalise-dont-special-case.md applied literally: the bug WAS the second path, and sizing/writing can no longer drift apart because they ask the same function.

Deliberately NOT changed: the Error(...) at :1870 still names Procs[ExternalProc[0]].Name. It is a diagnostic pointing the user at a declaration they wrote, so the Pascal identifier is the useful name there.

Verification

make compiler/pascal26converged after 2 round(s), fixedpoint verified.

The ticket's exact repro, all three --emit-obj writers:

build UND before UND after
x86-64 PalSysOpen/Read/Write open read write
riscv32 --platform=esp PalSysOpen/Read/Write open read write
xtensa --platform=esp PalSysOpen/Read/Write open read write

calloc/free correctly still resolve to their Pascal names — they carry no alias, so the fallback arm is exercised too. readelf reports no string-table corruption, which is the check that the sizing and writing halves agree.

The regression asserts the NAME, in both directions

frankB's acceptance criterion, taken literally. test/test_emit_obj.pas gains ext_alias_decl declared external name 'ext_aliased_link', and test-emit-obj asserts on riscv32 and xtensa:

readelf -sW ... | grep -q 'UND ext_aliased_link'      # the alias is there
! readelf -sW ... | grep -q 'ext_alias_decl'          # the Pascal name is NOT

The negative is the half that catches the bug. Confirmed as a control, not assumed — run against the pinned pre-fix binary and the fixed one:

BASELINE (pinned)  riscv32  alias=0 pascal_name=1 -> FAIL
BASELINE (pinned)  xtensa   alias=0 pascal_name=1 -> FAIL
FIXED (HEAD)       riscv32  alias=1 pascal_name=0 -> pass
FIXED (HEAD)       xtensa   alias=1 pascal_name=0 -> pass

The baseline compiled cleanly in both rows. That is the whole reason this survived: every existing check of the path is satisfied by an object that is wrong.

Log