Skip to content

bugc: use signed opcodes for signed operands - #339

Merged
gnidan merged 4 commits into
mainfrom
bugc-signed-ops
Oct 6, 2026
Merged

gnidan merged 4 commits into
mainfrom
bugc-signed-ops

Conversation

@gnidan

@gnidan gnidan commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

bugc never emitted SLT, SGT, SDIV or SMOD. Comparisons, / and % used the unsigned opcodes whatever the operand types, so with s.a holding -56, if (s.a < (0 as int8)) { ... } never ran, and (-7 as int8) / (2 as int8) was a large positive number. Since #334 and #337, signed values sit sign-extended across the word on the stack, so the signed opcodes are right at any width.

  • Comparisons (<, <=, >, >=) use SLT and SGT when an operand is a signed integer. == and != don't depend on sign.
  • / and % use SDIV and SMOD on signed integers: division truncates toward zero and a remainder has the sign of the dividend, as in Solidity. BUG arithmetic has no overflow checks (add, sub and mul wrap), so the least int256 divided by -1 wraps to itself, as SDIV gives, which is Solidity's unchecked result.
  • Arithmetic and comparison operators now reject a signed and an unsigned operand, as Solidity does, with a message that suggests a cast. Without that, the signed opcodes would read a uint256 at or above 2^255 as negative. So that x < 0 still works for a signed x, an integer literal (or its negation) now takes the type of the other operand when that type can hold it, and is a type error otherwise (x < 128 for an int8). Before, every literal was a uint256, so x == 1 was rejected while x < 1 compiled.
  • Constant folding uses the same rules, through a shared Ir.Utils.isSignedBinary / foldSigned.
  • A write to a mapping value or array element narrower than a slot now writes only its own bytes, as bugc: mask packed storage writes to each field's width #337 did for variables and struct fields. m[5] = -2 as int16 with m: mapping<uint256, int16> stored 0xff…fffe; now it stores 0xfffe, as Solidity does. (bugc gives each array element its own slot, without Solidity's packing, so only the mapping layout matches Solidity.) Reads were already right.

BUG has no shift operators, so there's nothing to do for SAR.

The tests are behavioral, at levels 0 to 3, plus type checker tests for the literal and signedness rules: every comparison on int8, int16, int128 and int256 operands (negative and negative, negative and positive, equal), / and % with each sign combination, the least int256 against -1, a few unsigned cases above 2^255 to show those don't change, literal versions of each that the optimizer folds, and literal operands such as x < 0, x == 1 and -1 < x. The mapping tests check the exact slot word for int8, int16 and int128 values, and read each back.

Two things I found along the way and left alone here:

  • Narrow results aren't wrapped to their width yet: (-128 as int8) / (-1 as int8) is 128 on the stack, where Solidity's unchecked result is -128. The same goes for narrow +, - and * overflow. A follow-up PR, stacked on this one, makes them wrap; the docs and changelog say so.
  • At level 2, compile time grows quickly with the number of if statements in one body (18 take about 0.3 s; 24 ran out of memory), and a body with many narrow mapping writes fails at levels 2 and 3 with "Cannot load value … not in stack or memory". Both happen without this change.

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-06 06:59 UTC

gnidan added 4 commits October 6, 2026 02:36
Comparisons always compiled to the EVM's unsigned LT and GT, so a
negative value compared greater than any positive one, and
`(-56 as int8) < (0 as int8)` was false at every level. Signed values are
sign-extended across the word, so the signed opcodes are right at any
width: choose them when an operand is a signed integer. Constant folding
compares by the same rule.
`/` and `%` always compiled to the EVM's unsigned DIV and MOD, so
`(-7 as int8) / (2 as int8)` was a large positive number at every
level. Use SDIV and SMOD when an operand is a signed integer: division
truncates toward zero and a remainder has the sign of the dividend, as
in Solidity. Arithmetic does not check for overflow, so the least int256
divided by -1 is itself, as SDIV gives. Constant folding divides by the
same rules.
A write to a mapping value or array element always wrote a full slot,
while reads sign-extend from the value's width. With
`m: mapping<uint256, int16>`, `m[5] = -2 as int16` stored a word of ff
bytes ending in fffe, where Solidity stores fffe. Write only the value's
bytes, as variables and struct fields already do.
…gnedness

An arithmetic or comparison operator accepted a signed and an unsigned
operand, and the signed opcodes then read a uint256 at or above 2^255 as
negative. Reject mixed signedness, as Solidity does, with a message that
suggests a cast. Every integer literal was a uint256, so that rule
alone would reject `x < 0` for a signed `x`; `x == 1` was already
rejected while `x < 1` compiled. Now an integer literal operand, or its
negation, takes the type of the other operand when that type can hold
its value, and is a type error otherwise.
@gnidan
gnidan merged commit 68aa2ee into main Oct 6, 2026
7 checks passed
@gnidan
gnidan deleted the bugc-signed-ops branch October 6, 2026 06:54
gnidan added a commit that referenced this pull request Oct 6, 2026
As in Solidity, an array or bytes index and a slice bound must be an
unsigned integer. Before, a signed index compiled, and since #339 its
comparisons are signed, so -1 would pass a bounds check.
gnidan added a commit that referenced this pull request Oct 6, 2026
As in Solidity, an array or bytes index and a slice bound must be an
unsigned integer. Before, a signed index compiled, and since #339 its
comparisons are signed, so -1 would pass a bounds check.
gnidan added a commit that referenced this pull request Oct 6, 2026
* bugc: add an assert instruction that reverts with Panic

An `assert` instruction takes a condition and a panic code. Code
generation jumps over a revert when the condition holds; otherwise the
code reverts with Solidity's `Panic(uint256)` error data, and the
REVERT carries a `revert` context with the panic code and a pointer
to that data. Dead code elimination keeps asserts, and constant
folding removes an assert whose condition is a true constant.

* bugc: give an array literal a memory reference type

An array literal's value had a scalar type, so code generation took its
length as the scalar's size: [1, 2, 3].length was 32.

* bugc: require an unsigned integer index

As in Solidity, an array or bytes index and a slice bound must be an
unsigned integer. Before, a signed index compiled, and since #339 its
comparisons are signed, so -1 would pass a bounds check.

* bugc: revert on out-of-bounds indexing

Indexing an array or bytes in memory, or a fixed-size array in
storage, now reverts with Panic(0x32) when the index is not less than
the length, as Solidity does. This holds for reads and writes, at
every level of a nested array. A slice x[a:b] reverts unless
a <= b <= x.length.

The check compares as unsigned words whatever the index's type, so a
negative value cast to an index is a huge index and reverts.

Before, an index past the end read or wrote whatever memory followed:
writing m[0][1] on [[1], [2]] overwrote m[1]'s length word.

The locals soundness test that sliced msg.data[0:4] runs with no
calldata, so it slices msg.data[0:0] now.

* bugc: document bounds checks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant