From 712263abd4f2d625f9568a272182103e88faa41d Mon Sep 17 00:00:00 2001 From: Graeme Geldenhuys Date: Tue, 16 Jun 2026 23:04:56 +0100 Subject: [PATCH] feat(rtl): integer div/mod by zero raises catchable EDivByZero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../pascal/blaise.codegen.native.x86_64.pas | 22 +++ .../src/main/pascal/blaise.codegen.qbe.pas | 44 ++++++ compiler/src/test/pascal/cp.test.cli.pas | 130 ++++++++++++++++++ .../test/pascal/cp.test.e2e.exceptions.pas | 25 ++++ docs/language-rationale.adoc | 39 ++++++ stdlib/src/main/pascal/sysutils.pas | 19 +++ 6 files changed, 279 insertions(+) diff --git a/compiler/src/main/pascal/blaise.codegen.native.x86_64.pas b/compiler/src/main/pascal/blaise.codegen.native.x86_64.pas index 5efa121..f79371c 100644 --- a/compiler/src/main/pascal/blaise.codegen.native.x86_64.pas +++ b/compiler/src/main/pascal/blaise.codegen.native.x86_64.pas @@ -178,6 +178,10 @@ type { Allocate a fresh local assembly label (".L"). } 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 diff --git a/compiler/src/main/pascal/blaise.codegen.qbe.pas b/compiler/src/main/pascal/blaise.codegen.qbe.pas index bd8c9e9..f73dda5 100644 --- a/compiler/src/main/pascal/blaise.codegen.qbe.pas +++ b/compiler/src/main/pascal/blaise.codegen.qbe.pas @@ -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; diff --git a/compiler/src/test/pascal/cp.test.cli.pas b/compiler/src/test/pascal/cp.test.cli.pas index d64a294..206d3c0 100644 --- a/compiler/src/test/pascal/cp.test.cli.pas +++ b/compiler/src/test/pascal/cp.test.cli.pas @@ -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(''); 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(''); 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(''); 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(''); 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 diff --git a/compiler/src/test/pascal/cp.test.e2e.exceptions.pas b/compiler/src/test/pascal/cp.test.e2e.exceptions.pas index b02de3c..370a66f 100644 --- a/compiler/src/test/pascal/cp.test.e2e.exceptions.pas +++ b/compiler/src/test/pascal/cp.test.e2e.exceptions.pas @@ -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); diff --git a/docs/language-rationale.adoc b/docs/language-rationale.adoc index f48eee1..063fa57 100644 --- a/docs/language-rationale.adoc +++ b/docs/language-rationale.adoc @@ -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 diff --git a/stdlib/src/main/pascal/sysutils.pas b/stdlib/src/main/pascal/sysutils.pas index 045d1f9..0307789 100644 --- a/stdlib/src/main/pascal/sysutils.pas +++ b/stdlib/src/main/pascal/sysutils.pas @@ -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