First code for StarForth v4 (JUSTIFICATION.md section 10, step 1): one node of the 32-instruction core as a C99 model, with cell width as a build parameter. - Node: P, A, B, F18 circular stacks (10 and 9 deep, D-2), word-addressed memory (D-1), 5% guard bands on every bounded list. - Instruction word: six 5-bit slots in 32 bits at every cell width. - Executor: all 32 opcodes of DECOMPOSITION.md 1.3. Cell arithmetic wraps explicitly; no signed overflow or implementation-defined shift. - Heat: per-opcode and per-call-target counters and the anti-clock, driven by instruction retirement (1.4, D-6 interim). - Slot packer and runner for tests, and a reference unsigned multiply in plain C99 with no 128-bit type. Tests run at 32- and 64-bit cells, and under ASan and UBSan. They cover every opcode and execute the first section 4 definitions (NIP SWAP OR NEGATE ROT 0< 0= 2DUP - U<) against the C operation each stands for. UM* as written in section 4 is exact only while u1 <= 2^(n-2). Two known failing cases are pinned in test_foundation.c until it is rewritten. DECOMPOSITION.md: record D-9, the instruction word is 32 bits at every cell width (ruled 2026-10-02). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
267 lines
11 KiB
C
267 lines
11 KiB
C
/* test_node.c -- the node: registers, memory, and the boundary on memory.
|
|
*
|
|
* The memory boundary does a job the stack boundaries do not: it is the landing
|
|
* place for a *linear* index error -- mem[-1], mem[V4_NODE_WORDS] -- which is
|
|
* inside the struct and therefore invisible to AddressSanitizer.
|
|
*
|
|
* It explicitly does NOT cover an out-of-range *word address*, where P, A or B
|
|
* has left the address space. That lands gigabytes away, outside any band.
|
|
* DECOMPOSITION.md does not say what such an access should do, so no policy is
|
|
* invented here; instead the limitation is measured and asserted, so the claim
|
|
* in node.h cannot rot. Closing that hole is a ruling on out-of-range
|
|
* addressing, not a wider band.
|
|
*/
|
|
#include "v4/node.h"
|
|
|
|
#include <stddef.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
static int failures = 0;
|
|
static int checks = 0;
|
|
|
|
#define CHECK(cond, ...) \
|
|
do { \
|
|
checks++; \
|
|
if (!(cond)) { \
|
|
failures++; \
|
|
printf(" FAIL %s:%d: ", __FILE__, __LINE__); \
|
|
printf(__VA_ARGS__); \
|
|
printf("\n"); \
|
|
} \
|
|
} while (0)
|
|
|
|
static void test_geometry(void)
|
|
{
|
|
/* The boundary is 5% of memory at each end, rounded up, never zero. */
|
|
CHECK(V4_MEM_BOUND == V4_GUARD_ELEMS(V4_NODE_WORDS),
|
|
"memory boundary is not 5%% of %u words", V4_NODE_WORDS);
|
|
CHECK(V4_MEM_BOUND >= 1u, "memory boundary must be at least one word");
|
|
CHECK((unsigned long)V4_MEM_BOUND * 100u
|
|
>= (unsigned long)V4_NODE_WORDS * V4_GUARD_PCT,
|
|
"memory boundary %u is less than %u%% of %u", V4_MEM_BOUND,
|
|
V4_GUARD_PCT, V4_NODE_WORDS);
|
|
|
|
/* At the default size that is a real cost, and naming it here means the
|
|
* number is on the record rather than discovered from a memory report. */
|
|
printf(" node: %u words + %u head + %u tail boundary "
|
|
"(%.1f%% overhead)\n",
|
|
V4_NODE_WORDS, V4_MEM_BOUND, V4_MEM_BOUND,
|
|
100.0 * 2.0 * (double)V4_MEM_BOUND / (double)V4_NODE_WORDS);
|
|
}
|
|
|
|
static void test_reset_state(void)
|
|
{
|
|
v4_node n;
|
|
v4_node_reset(&n);
|
|
|
|
CHECK(n.p == 0, "P after reset");
|
|
CHECK(n.a == 0, "A after reset");
|
|
CHECK(n.b == 0, "B after reset");
|
|
CHECK(v4_node_guards_intact(&n), "all boundaries intact after reset");
|
|
|
|
/* T, S and R live in the stacks, so they are checked through the stack
|
|
* API rather than as node fields. */
|
|
CHECK(v4_dstack_peek(&n.ds) == 0, "T after reset");
|
|
CHECK(v4_dstack_peek2(&n.ds) == 0, "S after reset");
|
|
CHECK(v4_rstack_peek(&n.rs) == 0, "R after reset");
|
|
}
|
|
|
|
static void test_guards_absent_before_reset(void)
|
|
{
|
|
/* A node that has never been reset holds whatever was on the stack. The
|
|
* check must notice, which is what proves it reads the boundary rather than
|
|
* returning a constant. */
|
|
v4_node *n = (v4_node *)malloc(sizeof *n);
|
|
if (n == NULL) {
|
|
failures++;
|
|
printf(" FAIL %s:%d: out of memory\n", __FILE__, __LINE__);
|
|
return;
|
|
}
|
|
memset(n, 0xA5, sizeof *n);
|
|
CHECK(!v4_node_guards_intact(n),
|
|
"boundaries must not read as intact before reset");
|
|
v4_node_reset(n);
|
|
CHECK(v4_node_guards_intact(n), "boundaries intact after reset");
|
|
free(n);
|
|
}
|
|
|
|
static void test_load_store_roundtrip(void)
|
|
{
|
|
v4_node n;
|
|
v4_node_reset(&n);
|
|
|
|
/* Every word, not a sample: a stray boundary in the middle of memory would
|
|
* not be found by spot checks. */
|
|
for (v4_cell i = 0; i < (v4_cell)V4_NODE_WORDS; i++) {
|
|
v4_node_store(&n, i, i * 3 + 1);
|
|
}
|
|
for (v4_cell i = 0; i < (v4_cell)V4_NODE_WORDS; i++) {
|
|
CHECK(v4_node_load(&n, i) == i * 3 + 1,
|
|
"word %lld: got %lld want %lld", (long long)i,
|
|
(long long)v4_node_load(&n, i), (long long)(i * 3 + 1));
|
|
}
|
|
CHECK(v4_node_guards_intact(&n), "memory boundary survived a full sweep");
|
|
}
|
|
|
|
static void test_load_store_edges(void)
|
|
{
|
|
/* The first and last words, which are the ones adjacent to the boundary.
|
|
* A boundary that is a word too wide would quietly steal word 0 or the
|
|
* last word, and the memory would still pass every interior test. */
|
|
v4_node n;
|
|
v4_node_reset(&n);
|
|
|
|
v4_node_store(&n, 0, 0x11111111);
|
|
v4_node_store(&n, (v4_cell)V4_NODE_WORDS - 1, 0x22222222);
|
|
CHECK(v4_node_load(&n, 0) == 0x11111111, "word 0 did not take its value");
|
|
CHECK(v4_node_load(&n, (v4_cell)V4_NODE_WORDS - 1) == 0x22222222,
|
|
"last word did not take its value");
|
|
CHECK(v4_node_guards_intact(&n), "edge writes disturbed the boundary");
|
|
}
|
|
|
|
static void test_boundary_is_adjacent_to_memory(void)
|
|
{
|
|
/* The band only catches a linear index error if it is immediately next to
|
|
* mem, so adjacency is asserted rather than assumed -- the compiler is free
|
|
* to reorder struct members, and a band that drifted to the far end of the
|
|
* struct would silently stop catching anything. */
|
|
CHECK(offsetof(v4_node, mem_guard_tail)
|
|
== offsetof(v4_node, mem) + sizeof(((v4_node *)0)->mem),
|
|
"tail boundary does not start immediately after memory");
|
|
CHECK(offsetof(v4_node, mem_guard_head)
|
|
+ sizeof(((v4_node *)0)->mem_guard_head)
|
|
== offsetof(v4_node, mem),
|
|
"head boundary does not end immediately before memory");
|
|
}
|
|
|
|
static void test_linear_overrun_low_is_caught(void)
|
|
{
|
|
/* A linear index error -- the kind an off-by-one in a loop bound or a
|
|
* pointer step produces. It lands in the head boundary, it is inside the
|
|
* struct so AddressSanitizer says nothing about it, and the boundary check
|
|
* is what reports it. Written through the boundary array rather than as
|
|
* mem[-1], because mem[-1] is out of bounds of a declared array and the
|
|
* standard lets the compiler do anything with it; the offsetof test above
|
|
* establishes that this *is* the word mem[-1] resolves to. */
|
|
v4_node n;
|
|
v4_node_reset(&n);
|
|
CHECK(v4_node_guards_intact(&n), "intact before the deliberate overrun");
|
|
|
|
n.mem_guard_head[V4_MEM_BOUND - 1u] = (v4_cell)0xDEADBEEF;
|
|
CHECK(!v4_node_guards_intact(&n),
|
|
"a linear access before mem was not detected");
|
|
|
|
v4_node_reset(&n);
|
|
CHECK(v4_node_guards_intact(&n), "intact after re-reset");
|
|
}
|
|
|
|
static void test_linear_overrun_high_is_caught(void)
|
|
{
|
|
v4_node n;
|
|
v4_node_reset(&n);
|
|
|
|
n.mem_guard_tail[0] = (v4_cell)0xDEADBEEF;
|
|
CHECK(!v4_node_guards_intact(&n),
|
|
"a linear access past the last word was not detected");
|
|
|
|
v4_node_reset(&n);
|
|
CHECK(v4_node_guards_intact(&n), "intact after re-reset");
|
|
}
|
|
|
|
static void test_out_of_range_word_address_is_not_covered_by_the_band(void)
|
|
{
|
|
/* What the band does NOT catch, asserted so the limitation stays visible
|
|
* and cannot be quietly forgotten.
|
|
*
|
|
* An out-of-range *word address* is a different animal from a linear index
|
|
* error. A v4_cell address of -1 becomes unsigned 0xFFFFFFFF, which is
|
|
* 2^32 words past mem -- 16 GB at 32-bit cells. That is far outside the
|
|
* struct, so no boundary can see it. The band was briefly documented as
|
|
* catching this; it does not, and the protection is a range check whose
|
|
* policy DECOMPOSITION.md does not define. This test records the gap by
|
|
* measuring it, so the number in node.h stays true. */
|
|
v4_cell neg = (v4_cell)-1;
|
|
uint64_t words_out = (uint64_t)(v4_ucell)neg;
|
|
uint64_t far = words_out * (uint64_t)sizeof(v4_cell);
|
|
printf(" out-of-range word address -1 lands %llu bytes past mem "
|
|
"(%.1f GB at this width): outside the band, as documented\n",
|
|
(unsigned long long)far, (double)far / 1073741824.0);
|
|
CHECK(far > (uint64_t)sizeof(v4_node),
|
|
"an out-of-range address should land outside the node");
|
|
|
|
/* The band is only V4_MEM_BOUND words, so even a modest overrun is caught
|
|
* only while it is within the band. Past that it is not, which is why the
|
|
* open question is a range check and not a wider band. */
|
|
CHECK(V4_MEM_BOUND < V4_NODE_WORDS / 2u,
|
|
"if the band were most of memory, this reasoning would be wrong");
|
|
}
|
|
|
|
static void test_ends_are_distinguishable(void)
|
|
{
|
|
/* The two boundaries carry different patterns precisely so that a failure
|
|
* says which end. Checked here on memory as well as in test_guard.c on the
|
|
* ring, because "which end" is the property that makes a report actionable. */
|
|
v4_node n;
|
|
v4_node_reset(&n);
|
|
CHECK((v4_ucell)n.mem_guard_head[0] == V4_GUARD_PATTERN_HEAD,
|
|
"memory head boundary pattern");
|
|
CHECK((v4_ucell)n.mem_guard_tail[0] == V4_GUARD_PATTERN_TAIL,
|
|
"memory tail boundary pattern");
|
|
CHECK(V4_GUARD_PATTERN_HEAD != V4_GUARD_PATTERN_TAIL,
|
|
"head and tail patterns are equal at V4_CELL_BITS=%d", V4_CELL_BITS);
|
|
}
|
|
|
|
static void test_workload_never_false_alarms(void)
|
|
{
|
|
/* A boundary that fires on legitimate use is worse than no boundary. Drive
|
|
* a workload across the whole address space, through both address registers
|
|
* and the program counter, and require every boundary to survive. */
|
|
v4_node n;
|
|
v4_node_reset(&n);
|
|
|
|
uint64_t s = 0xB5026F5AA96619E9ull;
|
|
for (int i = 0; i < 200000; i++) {
|
|
s ^= s << 13; s ^= s >> 7; s ^= s << 17;
|
|
|
|
v4_cell addr = (v4_cell)(s % (uint64_t)V4_NODE_WORDS);
|
|
v4_node_store(&n, addr, (v4_cell)s);
|
|
|
|
n.a = addr;
|
|
n.b = (v4_cell)((addr + 1u) % V4_NODE_WORDS);
|
|
n.p = (v4_cell)((addr + 2u) % V4_NODE_WORDS);
|
|
|
|
v4_dstack_push(&n.ds, (v4_cell)s);
|
|
v4_rstack_push(&n.rs, (v4_cell)(s >> 13));
|
|
if (i & 1) { (void)v4_dstack_pop(&n.ds); (void)v4_rstack_pop(&n.rs); }
|
|
}
|
|
CHECK(v4_node_guards_intact(&n),
|
|
"a boundary fired during ordinary workload");
|
|
CHECK(n.p < (v4_cell)V4_NODE_WORDS, "P left the address space");
|
|
CHECK(n.a < (v4_cell)V4_NODE_WORDS, "A left the address space");
|
|
CHECK(n.b < (v4_cell)V4_NODE_WORDS, "B left the address space");
|
|
}
|
|
|
|
int main(void)
|
|
{
|
|
printf("v4 node tests: V4_CELL_BITS=%d, %u words\n",
|
|
V4_CELL_BITS, V4_NODE_WORDS);
|
|
|
|
test_geometry();
|
|
test_reset_state();
|
|
test_guards_absent_before_reset();
|
|
test_load_store_roundtrip();
|
|
test_load_store_edges();
|
|
test_boundary_is_adjacent_to_memory();
|
|
test_linear_overrun_low_is_caught();
|
|
test_linear_overrun_high_is_caught();
|
|
test_out_of_range_word_address_is_not_covered_by_the_band();
|
|
test_ends_are_distinguishable();
|
|
test_workload_never_false_alarms();
|
|
|
|
printf(" %d checks, %d failures\n", checks, failures);
|
|
return failures ? 1 : 0;
|
|
}
|