PXX dialect

PXX is an Object Pascal dialect. It deliberately follows FPC behavior for many implemented features, but it does not aim for full FPC compatibility and it also has PXX-specific extensions.

Common supported surface

PXX supports a tested Object Pascal subset including:

PXX-specific or early surface

Some features are project-specific or still early:

Inline and auto-typed variables

PXX introduces two modern conveniences to the Pascal language surface, both of which are enabled by default:

  1. Inline variables: You can declare a variable using the var keyword anywhere inside a begin ... end block, rather than only in a routine's top-level var section. Inline variables are scoped to the block in which they are declared.
  2. Auto-typed variables: If a variable declaration includes an initializer, the compiler can automatically infer its type. You can either omit the type entirely or use the auto keyword.

For example:

begin
  var i := 0;              { inferred Integer, declared inline }
  var name: auto := 'PXX'; { explicit auto keyword }
  var x: Double := 3.14;   { inline, explicit type }
  
  for i := 1 to 10 do
    writeln(name, ' count: ', i);
end;

--no-lazy-var (or -fno-lazy-var) turns off inline var declarations, including the inferred var i := 0. --no-auto-var (or -fno-auto-var) turns off the auto keyword and inline loop variables (for var i := …); a plain var i := 0 in a block still compiles under it (pin v447).

FPC 3.2.2 has neither feature. It stops at the first inline var with "Illegal expression", so code that must also build with FPC declares its variables in a var section.

Calling conventions

A calling convention in PXX is mostly a property of the target, not of a routine. PXX's own routines use an internal convention, and calls into C use the target's C ABI. There is one per-routine choice, cdecl, and only two places where it changes anything: on a procedural type (see below), and on a routine with a body whose address goes into a cdecl procedural type. On x86-64 it gives that routine a System V prologue, and since pin v449 the routine and the type must agree on x86-64, i386, aarch64 and arm32.

On an external, a convention directive is decoration. These compile to the same code:

function sq(x: Double): Double; external 'libm.so.6' name 'sqrt';
function sq(x: Double): Double; cdecl; external 'libm.so.6' name 'sqrt';

Both call the C function correctly. The second is not more correct, only more explicit for a human reader: the call uses the platform C ABI because the routine is external, not because cdecl is written.

Every spelling is accepted in every position that can carry one, and exactly one combination means anything:

directive on an external on a routine with a body on a procedural type on a method declaration
cdecl accepted meaningful on x86-64: the routine gets a System V prologue, so C, or a cdecl procedural type, can call it. Accepted elsewhere. Since v449 it must match the procedural type the routine is stored in, on x86-64, i386, aarch64 and arm32 meaningful — see below accepted
register, stdcall, safecall, pascal, mwpascal accepted accepted accepted accepted

Note that register on a procedural type is not the exception cdecl is: register is FPC's own convention, not C's, so marking a signature with it would be marking it the wrong way.

FPC also type-checks the pairing: it refuses to assign a register routine to a stdcall procedural variable. Since pin v449 PXX checks the cdecl pairing. Here Twice lacks the cdecl its procedural type has:

program cd2;
type TF = function(x: Double): Double; cdecl;
function Twice(x: Double): Double; begin Twice := 2 * x; end;
var f: TF;
begin f := @Twice; WriteLn(f(21.0):0:1); end.

FPC refuses the assignment ("Incompatible types"), and so does PXX v449 on x86-64, i386, aarch64 and arm32:

pascal26:5: error: incompatible types: @Twice uses the Pascal convention but the procedural type uses cdecl -- declare both the same way

The commit that added the check (7d105ec21c) says it runs wherever a routine's address meets a procedural type (an assignment to a variable, a field or an element, and an argument), in both directions, and that external routines are exempt, because their convention is the library's; this page measured only the assignment above. On riscv32 cdecl on a bodied routine selects nothing, so there is nothing to mismatch and the program compiles. On xtensa and wasm32 this example is refused for a different reason: a Pascal routine with a by-value float parameter cannot be called through a cdecl pointer there yet.

Before v449 PXX compiled the program, and on some targets the call gave a wrong answer. With v448 (sha256 b2b325036c3b) it printed 0.0 on x86-64 and i386, and 42.0 on aarch64, arm32 and riscv32. With cdecl on Twice too, v448 and v449 print 42.0 on all five. Measured 2026-09-28 under QEMU user mode, v449 sha256 0ded1e5d04c8. Write cdecl on the routine whenever you take its address for a cdecl type. For the other conventions, code FPC accepts compiles here, and code PXX accepts may need the conventions matched up before FPC will take it back.

The exception: cdecl on a procedural type

On a procedural type — a function-pointer type — cdecl is not decoration. It marks the signature as C-ABI so that an indirect call through a value of that type marshals its arguments the C way. Leave it off a pointer to a real C function and the call is compiled with PXX's internal convention: it will build, run, and produce a wrong answer.

type
  TPlain = function(x: Double): Double;
  TCdecl = function(x: Double): Double; cdecl;

Given a dlsym'd pointer to a C double dtwice(double), calling it through TPlain yields 21.0 for an argument of 21.0 — the argument never reaches the register the C function reads — while TCdecl yields the correct 42.0.

The rule of thumb: if you are writing a type for a pointer to a C function, write cdecl on it, and on every Pascal routine you store in it. On an external routine the marker is documentation. On a bodied routine, leaving it off while storing the routine in a cdecl type is refused on x86-64, i386, aarch64 and arm32 since v449.

Routine directives

Calling conventions are not the only decorators, and the rest are not uniformly inert either. A directive here falls into one of three groups, and it is worth knowing which — an inert one is free to write, a load-bearing one changes what is compiled, and a rejected one stops the build.

Load-bearing

These change the routine. Several refuse to compile when they cannot be honored, which is deliberate: silently ignoring them would produce a working build that does the wrong thing.

directive effect
assembler the body is inline assembly; an ordinary begin body is an error
generator the routine becomes a generator, and must be a function (its result type is the yielded element type)
async asynchronous routine
stackless selects the stackless strategy for an async routine
interrupt raw hardware-vector ISR. Implemented for riscv32 (ESP32-C3) and xtensa Call0 (ESP32-S3) only; on any other target it is an error, naming iram as the alternative for an IDF-registered ISR
flexcolumn call arguments accept write-style :width[:decimals] modifiers
external the routine is a dynamically-linked import
virtual, override, dynamic, abstract method dispatch (on a class member declaration)
static on a class method declaration: no Self

Accepted and ignored

Written for the reader, or for FPC source compatibility. They parse and do nothing.

directive why it is inert
cdecl, register, stdcall, safecall, pascal, mwpascal the calling convention is the target's — see above. cdecl on a procedural type, and on a bodied routine stored in one, are the exceptions
inline the optimizer decides. At -O2 it inlines any routine that qualifies (a function, scalar result, at most six scalar by-value parameters, not external or a generator) whether or not you wrote inline, and never inlines one that does not qualify because you did
stackful the default async strategy; accepted so it can be stated explicitly
static, reintroduce on a plain routine (static is meaningful on a class method)
iram on targets other than xtensa and riscv32, where there is no IRAM to place anything in
deprecated, platform, experimental, unimplemented, library hint directives — see FPC compatibility. No usage warning is emitted yet
noreturn, noinline hints about a decision the compiler makes for itself
nostackframe an optimization PXX does not make; the frame is emitted anyway
far, near 16-bit memory models. There is nothing to address far
local FPC's unit-private visibility. PXX is whole-program and has no separate unit output, so a routine is local exactly when nothing else names it

Rather than consulting this table, ask the compiler: --warn-ignored-directives names the inert ones at the point of use, with the reason — see below.

overload is a case of its own: inert by default, required under --strict-overload. PXX resolves overloads without it; the flag makes the missing directive an error, the way FPC has it.

Not accepted

public, export, alias, compilerproc, internproc, rtlproc, hardfloat and softfloat are refused, and that is a decision rather than a gap. Each of them means something: the linkage group changes what --emit-obj produces, compilerproc and its neighbours mark a routine the compiler itself supplies, and hardfloat/softfloat select a float ABI — which is a real choice on the arm32 and riscv32 targets. Accepting one and ignoring it would compile something other than what was written, which is worse than refusing it. FPC sources using them need the directive removed, or the behaviour implemented.

weakexternal was on that list until 2026-09-14 and is now accepted, by the second route that sentence offers: it is implemented. So is varargs, since 2026-09-03: on a cdecl external it lets a Pascal program call a variadic C function with extra arguments, in either of FPC's two orders.

function sprintf(buf, fmt: PChar): Integer; cdecl; varargs;
  external 'libc.so.6' name 'sprintf';

You can call libc's own printf this way without flushing it yourself. A PXX program does not exit through libc, so a program that imports from libc calls fflush(NULL) for you after the unit finalizations, on every exit that runs them: the end of the program, Halt, and a runtime error. Output is still lost if the program is killed by a signal. Because libc buffers and PXX's own Write does not, printf output and WriteLn output can appear in a different order from the order of the calls when stdout is a pipe or a file. Call fflush(nil) between them if the order matters.

weakexternal — an optional import

weakexternal takes the same clause as external and emits the symbol as an undefined weak dynamic symbol:

function pthread_create(t, attr, fn, arg: Pointer): Integer; cdecl;
  weakexternal 'libc.so.6' name 'pthread_create';

A symbol declared external that the loader cannot resolve is a fatal symbol lookup error before main runs. One declared weakexternal is not: the loader leaves its GOT slot at zero, the program starts, and @f reads nil. The caller must test for nil — PXX inserts no check, exactly as FPC inserts none:

if @pthread_create <> nil then { use it } else { fall back };

It is for "use this C facility if the program already has it". What it buys is that one unit can reach a symbol that may be absent without every program including that unit failing to start.

A weak import does not make the program depend on its library. A library reached only through weak imports gets no DT_NEEDED entry, so its symbols read nil even when the library is installed. A program whose imports are all weak is linked statically, with no interpreter, and every weak symbol in it reads nil. This is by design. To have the weak symbol resolve when the library is present, declare at least one ordinary external from that same library: the program is then linked dynamically against it, and @f is non-nil wherever the symbol exists. With an ordinary external getpid from libc.so.6 beside the weak pthread_create, @pthread_create is non-nil. Without it, @pthread_create is nil, and so is a weak cbrt from libm.so.6 when the program has no ordinary import from libm.

Finding out which ones are inert here: --warn-ignored-directives

The table above tells you which directives are inert in general. The compiler will tell you which are inert at a particular declaration, with the reason, under an opt-in flag:

program x;
procedure P; cdecl;
begin end;
procedure R; iram;
begin end;
function Big(a,b,c,d,e,f,g: Integer): Integer; inline;
begin Big := a+g; end;
begin P; R; WriteLn(Big(1,2,3,4,5,6,7)); end.
$ pxx -O2 --warn-ignored-directives x.pas x
pascal26:5: warning: directive 'iram' ignored here: IRAM placement exists on the ESP targets (xtensa, riscv32) only; this target has no separate instruction RAM to place R in
pascal26:7: warning: directive 'inline' ignored here: the inliner takes at most six by-value scalar parameters and Big has 7

There is no cdecl line: on x86-64, cdecl on P selects the System V prologue, so it is not ignored. Built with --target=i386, the same program also warns that cdecl is ignored (pin v447). v449 and v450 print that warning on i386, aarch64 and arm32 too, including for a routine stored in a cdecl procedural type. There the warning is wrong: without the cdecl, the program in Calling conventions is refused by v449, and on i386 v448 printed 0.0 instead of 42.0. Keep the cdecl. After v450 (6b92d83493, in no pin yet) the warning is gone on those three targets and still given on riscv32, where cdecl selects nothing. Measured on 2026-09-28 with v450 (c19cc2d531e4) and the compiler built at 947170c4cb (1634f6483109).

It covers cdecl off x86-64, register, iram off the ESP targets, stackful, reintroduce, and inline when the routine cannot be inlined. Default behaviour is unchanged and silent — this reports, it never changes what is compiled.

Hint directives (deprecated, platform, …) are deliberately excluded. They are meant to be inert until usage warnings exist, so warning on them would fire on ordinary FPC source.

What the inline reason does not claim. It reports only causes knowable at the declaration — optimisation level, procedure vs function, assembler/generator/async/stackless, and more than six parameters. It never says the body is too complex, because the body has not been parsed at that point. So silence about a routine is not a promise that it will be inlined.

interrupt remains the one directive that is an error rather than a warning where it cannot be honored, because ignoring it would produce a working build that does the wrong thing.

Source compatibility posture

Prefer ordinary Object Pascal where possible. Use {$ifdef PXX} only for code that intentionally depends on PXX behavior.

Do not use {$ifdef FPC} to mean "Object Pascal compiler". PXX does not define FPC; that symbol belongs to Free Pascal.

Next

Async & coroutines

A pxx dialect extension with no FPC equivalent: a built-in cooperative, single-threaded coroutine scheduler and an async networking reactor, so one OS thread can drive thousands of concurrent tasks and connections without thread overhead. Coroutines run until they voluntarily yield.

uses scheduler;

procedure Worker(arg: Pointer);
begin
  writeln('tick'); CoSleep(100);   { non-blocking — scheduler runs others }
  writeln('tock'); CoYield;
end;

begin
  Spawn(@Worker, nil);             { SpawnSized for a custom stack (ESP32) }
  RunUntilDone;
end.

Full API — Spawn/SpawnSized, CoYield, CoSleep, RunUntilDone, and the async socket reactor — in Coroutines & Async.

Wrapper-free C import

A pxx unit can uses a plain C header directly — the compiler parses the .h and exposes its functions, types and #define constants to Pascal with no hand-written binding. This is how lib/pcl talks to GTK and OpenGL today (uses gtk3_c;, uses gl_c;). Input strings marshal to NUL-terminated C strings automatically; integer #define macros arrive as ordinary constants.

program users;
uses sqlite3;          { imports sqlite3.h directly — zero wrapper code }

var
  db, stmt: Pointer;
begin
  sqlite3_open('/tmp/users.db', @db);
  sqlite3_prepare_v2(db, 'select id, name from users', -1, @stmt, nil);

  while sqlite3_step(stmt) = SQLITE_ROW do   { SQLITE_ROW: a #define from the header }
  begin
    var id   := sqlite3_column_int(stmt, 0);    { auto-typed inline var: Integer }
    var name := sqlite3_column_text(stmt, 1);   { auto-typed: managed string }
    writeln(id, ' ', name);
  end;

  sqlite3_finalize(stmt);
  sqlite3_close(db);
end.

The nilpy frontend takes this further: with no address-of operator, it return-lifts trailing T** out-parameters automatically, so db = sqlite3_open(path) just returns the handle. See also the C frontend and its magic-link model.


Related: full dialect doc · FPC compatibility notes · compliance overview.