Skip to content
RTL Design Sherpa CocoTB Framework · Verification Infrastructure for RTL Testing
GitHub · Documentation Index · MIT License

memory_model.py

A NumPy-backed memory model for verification, with access tracking, named regions, and boundary checking built in — the thing your slave BFM talks to instead of a bare Python dict.

Overview

A Python dict works as a memory model until you care about speed, coverage, or strobe semantics — then you're writing a memory model anyway, badly, inside your testbench. This module is the real version: NumPy storage for fast bulk operations, per-address read/write counters so you can answer "did the test actually touch this region," named regions for organizing the address map, and bounds checking that fails loudly instead of silently wrapping. Transaction-based helpers let BFMs read and write it directly with packet objects.

Key Features

  • NumPy backend for memory operations
  • Per-address access tracking (reads and writes)
  • Named memory regions for logical organization
  • Boundary checking and validation
  • Coverage analysis and statistics
  • Transaction-based read/write operations
  • Detailed memory dumps with access information
  • Works directly with packet-based protocols

Core Class

MemoryModel

The memory model itself. One instance is one address space.

Constructor

MemoryModel(num_lines, bytes_per_line, log=None, preset_values=None, debug=False)

Parameters: - num_lines: Number of memory lines - bytes_per_line: Bytes per memory line - log: Logger instance (optional) - preset_values: Optional initial values for memory (list of integers) - debug: Enable detailed debug logging (default: False)

# Create 256-line memory with 4 bytes per line (1KB total)
memory = MemoryModel(num_lines=256, bytes_per_line=4, log=log, debug=True)

# Create memory with preset values
preset_data = [0xFF] * 1024  # Initialize all to 0xFF
memory = MemoryModel(256, 4, preset_values=preset_data)

Core Properties

  • num_lines: Number of memory lines
  • bytes_per_line: Bytes per memory line
  • size: Total memory size in bytes
  • mem: NumPy array containing memory data
  • preset_values: Original preset values (for reset operations)
  • read_access_map: NumPy array tracking read counts per address
  • write_access_map: NumPy array tracking write counts per address
  • regions: Dictionary of named memory regions
  • stats: Dictionary containing operation statistics

Basic Memory Operations

write(address, data, strobe=None)

Write data to memory with error handling and diagnostics.

Parameters: - address: Target memory address - data: Data to write (bytearray) - strobe: Optional write strobe. One bit per data byte (bit i enables data[i]). Defaults to all bytes enabled. A strobe with enable bits beyond len(data) is rejected with ValueError.

Raises: - TypeError: If data is not a bytearray - ValueError: If the write exceeds memory bounds, or the strobe is wider (in byte-enable bits) than the data

# Basic write operation
data = bytearray([0xDE, 0xAD, 0xBE, 0xEF])
memory.write(address=0x1000, data=data)

# Write with strobe (selective byte writing)
data = bytearray([0x12, 0x34, 0x56, 0x78])
strobe = 0b1010  # Write bytes 1 and 3 only
memory.write(address=0x2000, data=data, strobe=strobe)

read(address, length)

Read data from memory with error checking and access tracking.

Parameters: - address: Memory address to read from - length: Number of bytes to read

Returns: Bytearray containing the read data

Raises: - ValueError: If address or length is invalid, or read exceeds bounds

# Read 4 bytes from address 0x1000
data = memory.read(address=0x1000, length=4)
print(f"Read data: {[hex(b) for b in data]}")

# Read entire memory line
line_data = memory.read(address=0x2000, length=memory.bytes_per_line)

reset(to_preset=False)

Reset memory to initial state.

Parameters: - to_preset: If True, reset to preset values; if False, reset to all zeros

memory.reset()  # Reset to all zeros
memory.reset(to_preset=True)  # Reset to original preset values

expand(additional_lines)

Expand memory by adding additional lines.

Parameters: - additional_lines: Number of lines to add

# Add 128 more lines to existing memory
memory.expand(additional_lines=128)
print(f"New memory size: {memory.size} bytes")

Memory Regions

define_region(name, start_addr, end_addr, description=None)

Define a named memory region for better organization and diagnostics.

Parameters: - name: Region name - start_addr: Starting address (inclusive) - end_addr: Ending address (inclusive) - description: Optional description of the region

Returns: Self for method chaining

# Define memory regions
memory.define_region("bootrom", 0x0000, 0x0FFF, "Boot ROM region")
memory.define_region("ram", 0x1000, 0x8FFF, "Main RAM")
memory.define_region("peripherals", 0x9000, 0x9FFF, "Peripheral registers")
memory.define_region("flash", 0xA000, 0xFFFF, "Flash memory")

get_region_access_stats(name)

Get access statistics for a named region.

Parameters: - name: Region name

Returns: Dictionary with region access statistics or None if region doesn't exist

stats = memory.get_region_access_stats("ram")
if stats:
    print(f"RAM region: {stats['total_reads']} reads, {stats['total_writes']} writes")
    print(f"Coverage: {stats['read_percentage']:.1f}% read, {stats['write_percentage']:.1f}% written")
    print(f"Untouched addresses: {stats['untouched_addresses']}")

Transaction-Based Operations

write_transaction(transaction, check_required_fields=True, component_name="Component")

Write transaction data to memory with error handling.

Parameters: - transaction: The transaction to write to memory - check_required_fields: If True, validate that required fields exist - component_name: Component name for error messages

Returns: Tuple of (success, error_message)

# Write transaction to memory
success, error = memory.write_transaction(packet, component_name="TestMaster")
if success:
    log.info("Transaction written successfully")
else:
    log.error(f"Write failed: {error}")

read_transaction(transaction, update_transaction=True, check_required_fields=True, component_name="Component")

Read data from memory based on transaction address.

Parameters: - transaction: The transaction containing the address to read from - update_transaction: If True, update the transaction's data field with read value - check_required_fields: If True, validate that required fields exist - component_name: Component name for error messages

Returns: Tuple of (success, data, error_message)

# Read transaction from memory
success, data, error = memory.read_transaction(packet, component_name="TestSlave")
if success:
    log.info(f"Read data: 0x{data:X}")
else:
    log.error(f"Read failed: {error}")

Diagnostics and Analysis

dump(include_access_info=False)

Generate a detailed memory dump.

Parameters: - include_access_info: If True, include read/write access information

Returns: String with the memory dump

# Basic memory dump
dump_str = memory.dump()
print(dump_str)

# Dump with access information
detailed_dump = memory.dump(include_access_info=True)
print(detailed_dump)

get_stats()

Get comprehensive memory operation statistics.

Returns: Dictionary with statistics including coverage information

stats = memory.get_stats()
print(f"Total reads: {stats['reads']}")
print(f"Total writes: {stats['writes']}")
print(f"Read coverage: {stats['read_coverage']:.1%}")
print(f"Write coverage: {stats['write_coverage']:.1%}")
print(f"Boundary violations: {stats['boundary_violations']}")

Utility Methods

integer_to_bytearray(value, byte_length=None)

Convert an integer to a bytearray with error checking.

Parameters: - value: Integer value to convert - byte_length: Length of resulting bytearray (auto-calculated if None)

Returns: Bytearray representation of the value

Raises: - TypeError: If value is not an integer - ValueError: If value is negative - OverflowError: If value is too large for the specified byte_length

# Convert integer to bytearray
data = memory.integer_to_bytearray(0xDEADBEEF, 4)
print(f"Bytearray: {[hex(b) for b in data]}")

# Auto-calculate length
data = memory.integer_to_bytearray(0x1234)  # Will be 2 bytes

bytearray_to_integer(byte_array)

Convert a bytearray to an integer.

Parameters: - byte_array: Bytearray to convert

Returns: Integer representation of the bytearray

data = bytearray([0xEF, 0xBE, 0xAD, 0xDE])  # Little-endian
value = memory.bytearray_to_integer(data)
print(f"Integer value: 0x{value:X}")  # 0xDEADBEEF

Usage Patterns

Basic Memory Operations

# Initialize memory
memory = MemoryModel(num_lines=1024, bytes_per_line=4, log=log, debug=True)

# Write some data
test_data = bytearray([0x12, 0x34, 0x56, 0x78])
memory.write(0x1000, test_data)

# Read it back
read_data = memory.read(0x1000, 4)
assert read_data == test_data

# Check statistics
stats = memory.get_stats()
print(f"Operations: {stats['writes']} writes, {stats['reads']} reads")

Memory Region Management

# Define logical memory regions
memory = MemoryModel(4096, 4, log=log)

# Set up memory map
memory.define_region("vectors", 0x0000, 0x00FF, "Interrupt vectors")
memory.define_region("code", 0x0100, 0x7FFF, "Program code")
memory.define_region("data", 0x8000, 0xEFFF, "Data memory")
memory.define_region("io", 0xF000, 0xFFFF, "I/O registers")

# Use regions in testing
for addr in range(0x8000, 0x8100, 4):  # Write to data region
    data = bytearray([addr & 0xFF, (addr >> 8) & 0xFF, 0x00, 0x00])
    memory.write(addr, data)

# Analyze region usage
data_stats = memory.get_region_access_stats("data")
print(f"Data region usage: {data_stats}")

Transaction-Based Testing

class MemoryTestBench:
    def __init__(self):
        self.memory = MemoryModel(1024, 4, log=log)
        self.memory.define_region("test_area", 0x1000, 0x1FFF, "Test region")

    def test_write_read_sequence(self, packets):
        """Test a sequence of write/read transactions"""
        for packet in packets:
            if packet.cmd == 'WRITE':
                success, error = self.memory.write_transaction(packet, component_name="TestMaster")
                assert success, f"Write failed: {error}"

            elif packet.cmd == 'READ':
                success, data, error = self.memory.read_transaction(packet, component_name="TestMaster")
                assert success, f"Read failed: {error}"

                # Verify data matches expected
                if hasattr(packet, 'expected_data'):
                    assert data == packet.expected_data

    def generate_coverage_report(self):
        """Generate detailed coverage analysis"""
        stats = self.memory.get_stats()

        # Overall coverage
        print(f"Memory Coverage Report:")
        print(f"  Read coverage: {stats['read_coverage']:.1%}")
        print(f"  Write coverage: {stats['write_coverage']:.1%}")
        print(f"  Untouched bytes: {stats['untouched_bytes']}")

        # Per-region coverage
        region_stats = self.memory.get_region_access_stats("test_area")
        if region_stats:
            print(f"Test Area Coverage:")
            print(f"  Total accesses: {region_stats['total_reads'] + region_stats['total_writes']}")
            print(f"  Untouched: {region_stats['untouched_addresses']} addresses")

Advanced Memory Patterns

class AdvancedMemoryModel(MemoryModel):
    """Extended memory model with additional features"""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.access_patterns = {}
        self.hotspots = []

    def write(self, address, data, strobe=None):
        """Override write to track access patterns"""
        super().write(address, data, strobe)
        self._track_access_pattern(address, 'write')

    def read(self, address, length):
        """Override read to track access patterns"""
        data = super().read(address, length)
        self._track_access_pattern(address, 'read')
        return data

    def _track_access_pattern(self, address, operation):
        """Track access patterns for analysis"""
        if address not in self.access_patterns:
            self.access_patterns[address] = {'reads': 0, 'writes': 0, 'sequence': []}

        self.access_patterns[address][f'{operation}s'] += 1
        self.access_patterns[address]['sequence'].append(operation)

    def find_hotspots(self, min_accesses=10):
        """Find memory hotspots (frequently accessed addresses)"""
        hotspots = []
        for addr, pattern in self.access_patterns.items():
            total_accesses = pattern['reads'] + pattern['writes']
            if total_accesses >= min_accesses:
                hotspots.append((addr, total_accesses, pattern))

        # Sort by access count
        hotspots.sort(key=lambda x: x[1], reverse=True)
        self.hotspots = hotspots
        return hotspots

    def analyze_access_patterns(self):
        """Analyze memory access patterns"""
        patterns = {
            'sequential_reads': 0,
            'sequential_writes': 0,
            'read_after_write': 0,
            'write_after_read': 0
        }

        for addr, pattern in self.access_patterns.items():
            sequence = pattern['sequence']
            for i in range(len(sequence) - 1):
                current = sequence[i]
                next_op = sequence[i + 1]

                if current == 'read' and next_op == 'read':
                    patterns['sequential_reads'] += 1
                elif current == 'write' and next_op == 'write':
                    patterns['sequential_writes'] += 1
                elif current == 'write' and next_op == 'read':
                    patterns['read_after_write'] += 1
                elif current == 'read' and next_op == 'write':
                    patterns['write_after_read'] += 1

        return patterns

Performance Testing

def benchmark_memory_performance():
    """Benchmark memory model performance"""
    import time

    # Create large memory
    memory = MemoryModel(num_lines=65536, bytes_per_line=64)  # 4MB

    # Benchmark writes
    start_time = time.time()
    for i in range(10000):
        addr = i * 64
        data = bytearray([i & 0xFF] * 64)
        memory.write(addr, data)
    write_time = time.time() - start_time

    # Benchmark reads
    start_time = time.time()
    for i in range(10000):
        addr = i * 64
        data = memory.read(addr, 64)
    read_time = time.time() - start_time

    print(f"Performance Results:")
    print(f"  Writes: {10000/write_time:.0f} ops/sec")
    print(f"  Reads: {10000/read_time:.0f} ops/sec")

    # Memory usage analysis
    stats = memory.get_stats()
    print(f"  Coverage: {stats['write_coverage']:.1%}")

Error Handling

The model fails loudly on the mistakes that matter:

Boundary Checking

try:
    # This will raise ValueError
    memory.write(address=memory.size, data=bytearray([0xFF]))
except ValueError as e:
    print(f"Boundary violation: {e}")

Data Validation

try:
    # This will raise TypeError
    memory.write(address=0x1000, data="not a bytearray")
except TypeError as e:
    print(f"Invalid data type: {e}")

Transaction Error Handling

success, error = memory.write_transaction(invalid_packet)
if not success:
    print(f"Transaction failed: {error}")
    # Handle error gracefully

The transaction helpers return (success, error) tuples instead of raising, which is deliberate — a slave BFM wants to turn a bad access into a protocol error response, not into a Python exception that kills the test.

Integration with Protocols

GAXI Integration

class GAXIMemorySlave:
    def __init__(self, memory_lines=1024, line_size=4):
        self.memory = MemoryModel(memory_lines, line_size, log=self.log)

        # Define GAXI-specific regions
        self.memory.define_region("cacheable", 0x0000, 0x7FFF, "Cacheable memory")
        self.memory.define_region("device", 0x8000, 0xFFFF, "Device memory")

    @cocotb.coroutine
    def handle_write(self, packet):
        """Handle GAXI write transaction"""
        success, error = self.memory.write_transaction(packet, component_name="GAXISlave")
        if not success:
            packet.resp = 2  # SLVERR
            self.log.error(f"Write failed: {error}")
        else:
            packet.resp = 0  # OKAY

FIFO Integration

class FIFOMemoryBuffer:
    def __init__(self, depth=256, width=4):
        self.memory = MemoryModel(depth, width, log=self.log)
        self.write_ptr = 0
        self.read_ptr = 0
        self.count = 0

    def write_data(self, data):
        """Write data to FIFO buffer"""
        if self.count >= self.memory.num_lines:
            return False  # FIFO full

        self.memory.write(self.write_ptr * self.memory.bytes_per_line, data)
        self.write_ptr = (self.write_ptr + 1) % self.memory.num_lines
        self.count += 1
        return True

    def read_data(self):
        """Read data from FIFO buffer"""
        if self.count == 0:
            return None  # FIFO empty

        data = self.memory.read(self.read_ptr * self.memory.bytes_per_line, self.memory.bytes_per_line)
        self.read_ptr = (self.read_ptr + 1) % self.memory.num_lines
        self.count -= 1
        return data

Best Practices

1. Choose Appropriate Memory Sizes

Size the model to what the test needs — big enough to cover the address space, small enough to stay fast:

# For small tests
memory = MemoryModel(256, 4)  # 1KB

# For comprehensive tests
memory = MemoryModel(16384, 4)  # 64KB

# For stress tests
memory = MemoryModel(262144, 4)  # 1MB

2. Use Memory Regions for Organization

Regions turn a flat address space into a map you can reason about, and the per-region stats tell you which part of the map the test actually visited:

# Define logical memory layout
memory.define_region("boot", 0x0000, 0x0FFF)
memory.define_region("app", 0x1000, 0x7FFF)
memory.define_region("data", 0x8000, 0xEFFF)
memory.define_region("regs", 0xF000, 0xFFFF)

3. Monitor Memory Coverage

Coverage is how you catch "the address generator never left the first page":

# Regular coverage analysis
def check_coverage():
    stats = memory.get_stats()
    if stats['write_coverage'] < 0.8:
        log.warning("Low write coverage detected")

4. Handle Errors Gracefully

Check the transaction return values and map failures onto protocol responses, the way real hardware would:

# Always check transaction results
success, error = memory.write_transaction(packet)
if not success:
    # Handle error appropriately
    packet.response = 'ERROR'
    log.error(f"Memory operation failed: {error}")

5. Use Debug Mode During Development

Debug logging is verbose enough to slow a big regression. On while bringing the test up, off for the nightly:

# Enable debug for development
memory = MemoryModel(1024, 4, debug=True, log=log)

# Disable for production runs
memory = MemoryModel(1024, 4, debug=False)

The MemoryModel is the piece that makes the rest of the memory-centric workflow — scoreboards, region coverage, error injection — practical. Fast enough to stay out of the way, honest enough to tell you when the test didn't go where you thought it did.


Out-of-range accesses: the contract every slave BFM follows

An access that any memory-backed slave BFM cannot back -- any byte of it beyond the model's size -- is answered as an error, the same way by AXI4SlaveRead/Write, AXI5SlaveRead/Write, AXIL4SlaveRead/Write, AXIL5SlaveRead/Write, APBSlave and APB5Slave:

Aspect Behaviour
Response SLVERR (RRESP/BRESP = 2); PSLVERR = 1 on APB
Side effect none -- a write burst that overruns is not written at all, not partially
Read data OOR_READ_PATTERN (0xDEADDEAD) replicated to the beat width, via oor_read_data(n)
Log one WARNING naming the slave, the bus address, the size and the model size, via MemoryModel.oor_warning
Check MemoryModel.in_range(offset, length) -- AXI reads per beat, writes per whole burst

A master BFM sees it as it sees any error response: read_transaction / single_read raise RuntimeError, write_transaction / single_write return success=False. Before 2026-09-09 the families disagreed -- AXI4/AXI5 answered OKAY, dropped the write and returned the address as read data; AXIL answered SLVERR; APB grew its memory -- so a bridge probe that reached the right slave past the model got OKAY from one slave type and SLVERR from another (RTLDesignSherpa BRIDGE-008). APBSlave(error_overflow=False) keeps the old auto-expansion for a slave meant to accept any address.

This is the model's limit, not the design's. An address the RTL does not decode at all is the design's own error path (a DECERR from a subtractive slave, for instance) and is unrelated to this contract.