|
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¶
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 linesbytes_per_line: Bytes per memory linesize: Total memory size in bytesmem: NumPy array containing memory datapreset_values: Original preset values (for reset operations)read_access_map: NumPy array tracking read counts per addresswrite_access_map: NumPy array tracking write counts per addressregions: Dictionary of named memory regionsstats: 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
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.