feat(rtl): integer div/mod by zero raises catchable EDivByZero

Integer div and mod by zero previously trapped in hardware (SIGFPE),
uncatchable even inside try/except — a single bad divisor terminated the
process.  Both backends now emit a divisor-zero guard before each integer
idiv that raises a catchable EDivByZero exception via the normal exception
machinery, matching standard Pascal/Delphi semantics.

- SysUtils: add EDivByZero = class(Exception) and the _RaiseDivByZero helper
  that raises EDivByZero('Division by zero').
- QBE + native x86-64: emit a divisor==0 check before integer div/rem
  (32- and 64-bit); on zero, call SysUtils__RaiseDivByZero (which longjmps).
- The guard is emitted only when SysUtils is in scope (EDivByZero resolves
  through the symbol table); without SysUtils, division traps as before.
  This mirrors Delphi's placement of EDivByZero in System.SysUtils and keeps
  the always-linked runtime free of stdlib class machinery.
- Tests: in-process e2e proves the guard does not disturb normal division on
  both backends; shell-out cli tests (real compiler + linked stdlib) prove
  catchable div/mod-by-zero on both backends.
- Rationale recorded in language-rationale.adoc.

All three fixpoints green (FIXPOINT_OK, NATIVE_FIXPOINT_OK,
NATIVE_INTERNAL_OK); full suite OK (3345 tests).
This commit is contained in:
Graeme Geldenhuys 2026-06-16 23:04:56 +01:00
parent 5a15d4d15e
commit 712263abd4
6 changed files with 279 additions and 0 deletions

View file

@ -178,6 +178,10 @@ type
{ Allocate a fresh local assembly label (".L<prefix><N>"). }
function NewLabel(const APrefix: string): string;
{ True when a catchable EDivByZero can be raised (SysUtils, which declares
EDivByZero + _RaiseDivByZero, is in scope). Gates the div/mod zero
guard; without it a zero divisor traps in hardware as before. }
function DivGuardAvailable(): Boolean;
{ Register a global integer slot of the given type (idempotent; the first
registration's type wins). The width and signedness drive both the
.data directive and every load/store of the slot. }
@ -793,6 +797,11 @@ begin
Inc(FLabelCount);
end;
function TX86_64Backend.DivGuardAvailable(): Boolean;
begin
Result := (FSymTable <> nil) and (FSymTable.Lookup('EDivByZero') <> nil);
end;
procedure TX86_64Backend.AddGlobal(const AName: string; AType: TTypeDesc);
begin
if not FDataGlobals.ContainsKey(AName) then
@ -4779,6 +4788,7 @@ var
SetElem: TASTExpr;
ScEndLbl: string;
IsS: Boolean;
DivOkLbl: string;
begin
if AExpr is TNilLiteral then
begin
@ -6196,6 +6206,18 @@ begin
boMul: Self.Emit(#9'imulq %rcx, %rax');
boDiv, boMod:
begin
{ Divisor-zero guard: when SysUtils is in scope, a zero divisor
raises a catchable EDivByZero instead of trapping (SIGFPE).
The divisor is in %rcx; SysUtils__RaiseDivByZero longjmps and
never returns. }
if Self.DivGuardAvailable() then
begin
DivOkLbl := Self.NewLabel('divok');
Self.Emit(#9'testq %rcx, %rcx');
Self.Emit(#9'jne ' + DivOkLbl);
Self.Emit(#9'callq SysUtils__RaiseDivByZero');
Self.Emit(DivOkLbl + ':');
end;
{ 64-bit divide. Choose signed vs unsigned by the operand types:
if either side is an unsigned 64-bit type, use unsigned division
so the top bit is a magnitude bit, not a sign. cqto sign-extends

View file

@ -264,6 +264,13 @@ type
procedure EmitTryFinallyStmt(AStmt: TTryFinallyStmt);
procedure EmitTryExceptStmt(AStmt: TTryExceptStmt);
procedure EmitRaiseStmt(AStmt: TRaiseStmt);
{ Returns True when a catchable EDivByZero can be raised, i.e. SysUtils
(which declares EDivByZero + _RaiseDivByZero) is in scope. When False
the div/mod guard is omitted and a zero divisor traps in hardware. }
function DivGuardAvailable(): Boolean;
{ Emit a divisor==0 check before an integer div/rem. ADivisor is the QBE
temp holding the right operand; AIsLong selects l vs w comparison. }
procedure EmitDivZeroGuard(const ADivisor: string; AIsLong: Boolean);
procedure EmitCompoundStmt(AStmt: TCompoundStmt);
procedure EmitAssignment(AAssign: TAssignment);
procedure EmitFieldAssignment(AAssign: TFieldAssignment);
@ -2716,6 +2723,39 @@ begin
end;
end;
function TCodeGenQBE.DivGuardAvailable(): Boolean;
begin
{ EDivByZero is declared in SysUtils. If it resolves through the active
symbol table, SysUtils is in scope and $SysUtils_RaiseDivByZero is
linkable, so the guard can raise a catchable exception. Otherwise the
divisor-zero path must fall through to the hardware trap. }
Result := (FSymTable <> nil) and (FSymTable.Lookup('EDivByZero') <> nil);
end;
procedure TCodeGenQBE.EmitDivZeroGuard(const ADivisor: string; AIsLong: Boolean);
var
CmpTemp: string;
RaiseLbl: string;
OkLbl: string;
begin
if not Self.DivGuardAvailable() then
Exit;
CmpTemp := AllocTemp();
RaiseLbl := AllocLabel('divzero_raise');
OkLbl := AllocLabel('divzero_ok');
if AIsLong then
EmitLine(Format(' %s =w ceql %s, 0', [CmpTemp, ADivisor]))
else
EmitLine(Format(' %s =w ceqw %s, 0', [CmpTemp, ADivisor]));
EmitLine(Format(' jnz %s, @%s, @%s', [CmpTemp, RaiseLbl, OkLbl]));
EmitLine(Format('@%s', [RaiseLbl]));
EmitLine(' call $SysUtils__RaiseDivByZero()');
{ _RaiseDivByZero longjmps and never returns; the jmp keeps QBE happy by
giving the block a terminator. }
EmitLine(Format(' jmp @%s', [OkLbl]));
EmitLine(Format('@%s', [OkLbl]));
end;
procedure TCodeGenQBE.EmitForStmt(AStmt: TForStmt);
var
LblCond: string;
@ -12206,6 +12246,8 @@ begin
Op := 'add';
end;
end;
if BinExpr.Op in [boDiv, boMod] then
Self.EmitDivZeroGuard(R, True);
EmitLine(Format(' %s =l %s %s, %s', [T, Op, L, R]));
end
{ Float arithmetic/comparison: QBE uses d/s typed instructions.
@ -12345,6 +12387,8 @@ begin
else
Op := 'add';
end;
if BinExpr.Op in [boDiv, boMod] then
Self.EmitDivZeroGuard(R, False);
EmitLine(Format(' %s =w %s %s, %s', [T, Op, L, R]));
end;
Result := T;

View file

@ -44,6 +44,14 @@ type
function RunCompiler(const AArgs: array of string;
out ACombined: string): Integer;
function WriteScratchSource(const ASrc: string): string;
{ Run a produced binary, capturing stdout/stderr; returns its exit code. }
function RunBinary(const AExe: string; out ACombined: string): Integer;
{ Compile ASrc with the given backend (empty = default QBE), link against
the full RTL, run it, and report stdout + exit code. Used for features
that need stdlib units loaded + linked, which the in-process e2e harness
cannot do. }
function CompileRunFull(const ASrc, ABackend: string;
out AStdout: string; out AExitCode: Integer): Boolean;
protected
procedure SetUp; override;
published
@ -60,6 +68,11 @@ type
procedure TestWrongBackendAssemblerRejected; { addendum 2: qbe + --assembler }
procedure TestEmitIrStillValidatesAssembler; { addendum 1: validate runs in stdout mode }
procedure TestAssemblerLineInHelp; { DescribeOptions drives --help }
{ ---- div/mod by zero raises a catchable EDivByZero (needs stdlib) ---- }
procedure TestDivByZeroCaught_QBE;
procedure TestDivByZeroCaught_Native;
procedure TestModByZeroCaught_QBE;
procedure TestModByZeroCaught_Native;
end;
implementation
@ -348,6 +361,123 @@ begin
Pos('--assembler', Out_) >= 0);
end;
function TCLIContractTests.RunBinary(const AExe: string;
out ACombined: string): Integer;
var
Proc: TProcess;
Chunk: string;
begin
Proc := TProcess.Create(nil);
try
Proc.Executable := AExe;
Proc.Execute();
ACombined := '';
repeat
Chunk := Proc.ReadOutput();
ACombined := ACombined + Chunk;
until (Chunk = '') and not Proc.Running;
Proc.WaitOnExit();
Result := Proc.ExitCode;
finally
Proc.Free();
end;
end;
function TCLIContractTests.CompileRunFull(const ASrc, ABackend: string;
out AStdout: string; out AExitCode: Integer): Boolean;
var
SrcPath, BinPath, CompileOut: string;
EC: Integer;
begin
Result := False;
SrcPath := WriteScratchSource(ASrc);
BinPath := FScratch + 'cli_run_' + IntToStr(FCounter);
if ABackend = '' then
EC := RunCompiler(['--source', SrcPath,
'--unit-path', FRTLPath, '--unit-path', FStdlibPath,
'--output', BinPath], CompileOut)
else
EC := RunCompiler(['--source', SrcPath, '--backend', ABackend,
'--unit-path', FRTLPath, '--unit-path', FStdlibPath,
'--output', BinPath], CompileOut);
if EC <> 0 then
begin
AStdout := 'compile failed: ' + CompileOut;
AExitCode := EC;
Exit;
end;
AExitCode := RunBinary(BinPath, AStdout);
Result := True;
end;
const
SrcDivByZeroCaught =
'program P;' + LineEnding +
'uses SysUtils;' + LineEnding +
'var a, b: Integer;' + LineEnding +
'begin' + LineEnding +
' a := 10; b := 0;' + LineEnding +
' try' + LineEnding +
' WriteLn(a div b)' + LineEnding +
' except' + LineEnding +
' on E: EDivByZero do WriteLn(''caught: '' + E.Message)' + LineEnding +
' end;' + LineEnding +
' WriteLn(''after'')' + LineEnding +
'end.';
SrcModByZeroCaught =
'program P;' + LineEnding +
'uses SysUtils;' + LineEnding +
'var a, b: Integer;' + LineEnding +
'begin' + LineEnding +
' a := 10; b := 0;' + LineEnding +
' try' + LineEnding +
' WriteLn(a mod b)' + LineEnding +
' except' + LineEnding +
' on E: EDivByZero do WriteLn(''mod caught'')' + LineEnding +
' end' + LineEnding +
'end.';
procedure TCLIContractTests.TestDivByZeroCaught_QBE;
var Out_: string; EC: Integer;
begin
if not CompilerAvailable() then begin Ignore('<toolchain-missing>'); Exit; end;
AssertTrue('compile+run', CompileRunFull(SrcDivByZeroCaught, '', Out_, EC));
AssertEquals('exit code 0 (exception caught, not SIGFPE)', 0, EC);
AssertTrue('EDivByZero caught with message',
Pos('caught: Division by zero', Out_) >= 0);
AssertTrue('execution continued past the catch', Pos('after', Out_) >= 0);
end;
procedure TCLIContractTests.TestDivByZeroCaught_Native;
var Out_: string; EC: Integer;
begin
if not CompilerAvailable() then begin Ignore('<toolchain-missing>'); Exit; end;
AssertTrue('compile+run', CompileRunFull(SrcDivByZeroCaught, 'native', Out_, EC));
AssertEquals('exit code 0 (exception caught, not SIGFPE)', 0, EC);
AssertTrue('EDivByZero caught with message',
Pos('caught: Division by zero', Out_) >= 0);
AssertTrue('execution continued past the catch', Pos('after', Out_) >= 0);
end;
procedure TCLIContractTests.TestModByZeroCaught_QBE;
var Out_: string; EC: Integer;
begin
if not CompilerAvailable() then begin Ignore('<toolchain-missing>'); Exit; end;
AssertTrue('compile+run', CompileRunFull(SrcModByZeroCaught, '', Out_, EC));
AssertEquals('exit code 0', 0, EC);
AssertTrue('mod by zero caught', Pos('mod caught', Out_) >= 0);
end;
procedure TCLIContractTests.TestModByZeroCaught_Native;
var Out_: string; EC: Integer;
begin
if not CompilerAvailable() then begin Ignore('<toolchain-missing>'); Exit; end;
AssertTrue('compile+run', CompileRunFull(SrcModByZeroCaught, 'native', Out_, EC));
AssertEquals('exit code 0', 0, EC);
AssertTrue('mod by zero caught', Pos('mod caught', Out_) >= 0);
end;
{ ---- Registration ---- }
initialization

View file

@ -54,6 +54,12 @@ type
{ Exit inside the second try block of a function must pop its exc frame;
a later raise must still dispatch correctly (stale-frame regression). }
procedure TestRun_ExitInSecondTry_LaterRaiseStillWorks;
{ The div/mod zero guard (emitted when SysUtils is in scope) must not
disturb normal division. Catchable-EDivByZero behaviour needs the
stdlib loaded + linked, which the in-process harness cannot do; that
is covered by the shell-out test in cp.test.cli.pas. }
procedure TestRun_DivNonZero_StillWorks;
end;
implementation
@ -216,6 +222,19 @@ const
end.
''';
{ Non-zero divisor: the SysUtils-gated div/mod zero guard must not disturb
normal division on either backend. }
SrcDivNonZeroStillWorks = '''
program P;
uses SysUtils;
var a, b: Integer;
begin
a := 20; b := 4;
WriteLn(a div b);
WriteLn(a mod 7)
end.
''';
SrcExcBase =
'''
program P;
@ -526,6 +545,12 @@ begin
'100' + LE + '50' + LE + 'caught' + LE + 'done' + LE, 0);
end;
procedure TE2EExceptionTests.TestRun_DivNonZero_StillWorks;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit; end;
AssertRunsOnAll(SrcDivNonZeroStillWorks, '5' + LE + '6' + LE, 0);
end;
initialization
RegisterTest(TE2EExceptionTests);

View file

@ -1720,6 +1720,45 @@ OnClause = "on" [ IDENT ":" ] TypeRef DO Stmt ;
* *Requiring `on E: T` to always have a variable binding* — Delphi and FPC
both permit the no-binding form `on T do`; Blaise follows suit.
=== Integer division by zero
==== Decision
Integer `div` and `mod` by zero raise a catchable `EDivByZero` exception
(declared in `SysUtils`, a subclass of `Exception`) rather than trapping in
hardware. The code generator emits a divisor-equals-zero check before each
integer `idiv`; when the divisor is zero it calls the runtime helper
`_RaiseDivByZero`, which raises `EDivByZero('Division by zero')` through the
normal exception machinery. Both backends (QBE and native x86-64) emit the
guard.
The guard is emitted *only when `SysUtils` is in scope* — i.e. when
`EDivByZero` resolves through the active symbol table. A program that does
not use `SysUtils` cannot name the exception class anyway, and its integer
division falls through to the hardware behaviour (a `SIGFPE` trap) as before.
This mirrors Delphi, where `EDivByZero` lives in `System.SysUtils` and the
catchable behaviour is tied to that unit.
==== Rationale
A bare hardware trap is uncatchable: a `try`/`except` around `a div b`
cannot recover from a zero divisor, so a single bad input terminates the
whole process. Routing division by zero through the exception system makes
it recoverable, matching standard Pascal/Delphi semantics and the
expectations of code that validates input by catching the exception rather
than pre-checking every divisor.
==== Alternatives Rejected
* *Leave it as a hardware trap* — zero runtime cost, but a zero divisor
crashes hard and uncatchably; unacceptable for code that handles untrusted
input.
* *Define `EDivByZero` in the always-linked runtime so the guard is
unconditional* — the runtime (`blaise_exc.pas`) deliberately does not
depend on stdlib, and exception classes (with their vtable + TypeInfo) are
a stdlib concern. Gating the guard on `SysUtils` keeps the runtime free of
class machinery and matches Delphi's unit placement.
---
== Project Governance

View file

@ -38,6 +38,20 @@ type
EConvertError = class(Exception)
end;
{ Raised by integer `div`/`mod` when the divisor is zero. The compiler
emits a divisor==0 guard before each integer division that calls
_RaiseDivByZero (below) when SysUtils is in scope; without SysUtils the
division traps in hardware (SIGFPE) as before. Matches Delphi, where
EDivByZero lives in System.SysUtils. }
EDivByZero = class(Exception)
end;
{ _RaiseDivByZero raises EDivByZero('Division by zero'). Called by the
compiler-emitted div/mod guard; not intended for direct use. Declared in
the interface so the code generator can reference the $SysUtils_RaiseDivByZero
symbol. }
procedure _RaiseDivByZero;
{ BoolToStr — not a compiler built-in; pure Pascal implementation }
function BoolToStr(B: Boolean; AUseBoolStrs: Boolean = False): string;
@ -57,6 +71,11 @@ begin
Self.FMessage := AMessage
end;
procedure _RaiseDivByZero;
begin
raise EDivByZero.Create('Division by zero')
end;
function BoolToStr(B: Boolean; AUseBoolStrs: Boolean): string;
begin
if B then