Initialized MiniProfiler project

- Contains the host code with a protocol implementation, data analyser and web-based visualiser
This commit is contained in:
Atharva Sawant
2025-11-27 20:34:41 +05:30
commit 852957a7de
20 changed files with 3845 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
"""MiniProfiler Host Application
A Python-based host application for profiling embedded STM32 applications.
"""
__version__ = "0.1.0"

View File

@@ -0,0 +1,314 @@
"""Profiling data analysis and visualization data generation.
This module processes raw profiling records to build call trees,
compute statistics, and generate data structures for visualization.
"""
import logging
from typing import List, Dict, Optional, Any
from collections import defaultdict
from dataclasses import dataclass, field
from .protocol import ProfileRecord
from .symbolizer import Symbolizer
logger = logging.getLogger(__name__)
@dataclass
class FunctionStats:
"""Statistics for a single function."""
name: str
address: int
call_count: int = 0
total_time_us: int = 0
min_time_us: int = float('inf')
max_time_us: int = 0
self_time_us: int = 0 # Time excluding children
def update(self, duration_us: int):
"""Update statistics with a new duration measurement."""
self.call_count += 1
self.total_time_us += duration_us
self.min_time_us = min(self.min_time_us, duration_us)
self.max_time_us = max(self.max_time_us, duration_us)
@property
def avg_time_us(self) -> float:
"""Average execution time in microseconds."""
return self.total_time_us / self.call_count if self.call_count > 0 else 0
@dataclass
class CallTreeNode:
"""Node in the call tree."""
name: str
address: int
entry_time: int
duration_us: int
depth: int
children: List['CallTreeNode'] = field(default_factory=list)
def add_child(self, node: 'CallTreeNode'):
"""Add a child node to this node."""
self.children.append(node)
def to_flamegraph_dict(self) -> Dict[str, Any]:
"""Convert to d3-flame-graph format.
Returns:
Dictionary in the format:
{
"name": "function_name",
"value": duration_in_microseconds,
"children": [child_dicts...]
}
"""
result = {
"name": self.name,
"value": self.duration_us
}
if self.children:
result["children"] = [child.to_flamegraph_dict() for child in self.children]
return result
def to_timeline_dict(self) -> Dict[str, Any]:
"""Convert to timeline/flame chart format.
Returns:
Dictionary with timing information for Plotly timeline
"""
return {
"name": self.name,
"start": self.entry_time,
"duration": self.duration_us,
"depth": self.depth,
"children": [child.to_timeline_dict() for child in self.children]
}
class ProfileAnalyzer:
"""Analyzes profiling data and generates visualization data."""
def __init__(self, symbolizer: Optional[Symbolizer] = None):
"""Initialize the analyzer.
Args:
symbolizer: Symbolizer for resolving addresses to names
"""
self.symbolizer = symbolizer
self.records: List[ProfileRecord] = []
self.stats: Dict[int, FunctionStats] = {} # addr -> stats
self.call_tree: Optional[CallTreeNode] = None
self.timeline_events: List[Dict[str, Any]] = []
def add_records(self, records: List[ProfileRecord]):
"""Add profiling records for analysis.
Args:
records: List of ProfileRecord objects
"""
self.records.extend(records)
logger.debug(f"Added {len(records)} records, total: {len(self.records)}")
def clear(self):
"""Clear all recorded data."""
self.records.clear()
self.stats.clear()
self.call_tree = None
self.timeline_events.clear()
logger.info("Cleared all profiling data")
def _resolve_name(self, addr: int) -> str:
"""Resolve address to function name."""
if self.symbolizer:
return self.symbolizer.resolve_name(addr)
return f"func_0x{addr:08x}"
def compute_statistics(self) -> Dict[int, FunctionStats]:
"""Compute statistics for all functions.
Returns:
Dictionary mapping addresses to FunctionStats
"""
self.stats.clear()
for record in self.records:
addr = record.func_addr
name = self._resolve_name(addr)
if addr not in self.stats:
self.stats[addr] = FunctionStats(name=name, address=addr)
self.stats[addr].update(record.duration_us)
logger.info(f"Computed statistics for {len(self.stats)} functions")
return self.stats
def build_call_tree(self) -> Optional[CallTreeNode]:
"""Build call tree from profiling records.
The call tree is built using the depth field to determine
parent-child relationships.
Returns:
Root node of the call tree, or None if no records
"""
if not self.records:
return None
# Sort records by entry time to process in chronological order
sorted_records = sorted(self.records, key=lambda r: r.entry_time)
# Stack to track current path in the tree
# stack[depth] = node at that depth
stack: List[CallTreeNode] = []
root = None
for record in sorted_records:
name = self._resolve_name(record.func_addr)
node = CallTreeNode(
name=name,
address=record.func_addr,
entry_time=record.entry_time,
duration_us=record.duration_us,
depth=record.depth
)
# Adjust stack to current depth
while len(stack) > record.depth:
stack.pop()
# Add node to tree
if record.depth == 0:
# Root level function
if root is None:
root = node
stack = [root]
else:
# Multiple root-level functions - create synthetic root
if not isinstance(root.name, str) or not root.name.startswith("__root__"):
synthetic_root = CallTreeNode(
name="__root__",
address=0,
entry_time=0,
duration_us=0,
depth=-1
)
synthetic_root.add_child(root)
root = synthetic_root
stack = [root]
root.add_child(node)
# Update root duration to encompass all children
root.duration_us = max(root.duration_us,
node.entry_time + node.duration_us)
else:
# Child function
if len(stack) >= record.depth:
parent = stack[record.depth - 1]
parent.add_child(node)
else:
logger.warning(f"Orphan node at depth {record.depth}: {name}")
continue
# Push to stack if we're going deeper
if len(stack) == record.depth:
stack.append(node)
elif len(stack) == record.depth + 1:
stack[record.depth] = node
self.call_tree = root
logger.info(f"Built call tree with {len(sorted_records)} nodes")
return root
def to_flamegraph_json(self) -> Dict[str, Any]:
"""Generate flame graph data in d3-flame-graph format.
Returns:
Dictionary suitable for d3-flame-graph
"""
if self.call_tree is None:
self.build_call_tree()
if self.call_tree is None:
return {"name": "root", "value": 0, "children": []}
return self.call_tree.to_flamegraph_dict()
def to_timeline_json(self) -> List[Dict[str, Any]]:
"""Generate timeline data for flame chart visualization.
Returns:
List of events for timeline/flame chart
"""
events = []
for record in sorted(self.records, key=lambda r: r.entry_time):
name = self._resolve_name(record.func_addr)
events.append({
"name": name,
"start": record.entry_time,
"end": record.entry_time + record.duration_us,
"duration": record.duration_us,
"depth": record.depth
})
return events
def to_statistics_json(self) -> List[Dict[str, Any]]:
"""Generate statistics table data.
Returns:
List of function statistics dictionaries
"""
if not self.stats:
self.compute_statistics()
stats_list = []
for func_stats in self.stats.values():
stats_list.append({
"name": func_stats.name,
"address": f"0x{func_stats.address:08x}",
"calls": func_stats.call_count,
"total_us": func_stats.total_time_us,
"avg_us": func_stats.avg_time_us,
"min_us": func_stats.min_time_us,
"max_us": func_stats.max_time_us,
})
# Sort by total time (descending)
stats_list.sort(key=lambda x: x["total_us"], reverse=True)
return stats_list
def get_summary(self) -> Dict[str, Any]:
"""Get summary statistics.
Returns:
Dictionary with summary information
"""
if not self.stats:
self.compute_statistics()
total_records = len(self.records)
total_functions = len(self.stats)
total_time = sum(s.total_time_us for s in self.stats.values())
hottest = None
if self.stats:
hottest = max(self.stats.values(), key=lambda s: s.total_time_us)
return {
"total_records": total_records,
"total_functions": total_functions,
"total_time_us": total_time,
"hottest_function": hottest.name if hottest else None,
"hottest_time_us": hottest.total_time_us if hottest else 0,
}

90
host/miniprofiler/cli.py Normal file
View File

@@ -0,0 +1,90 @@
"""Command-line interface for MiniProfiler."""
import argparse
import logging
import sys
from .web_server import create_app
def setup_logging(verbose: bool = False):
"""Configure logging.
Args:
verbose: Enable verbose (DEBUG) logging
"""
level = logging.DEBUG if verbose else logging.INFO
logging.basicConfig(
level=level,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
def main():
"""Main entry point for the CLI."""
parser = argparse.ArgumentParser(
description='MiniProfiler - Real-time Embedded Profiling Visualization'
)
parser.add_argument(
'--host',
type=str,
default='0.0.0.0',
help='Host address to bind to (default: 0.0.0.0)'
)
parser.add_argument(
'--port',
type=int,
default=5000,
help='Port number to listen on (default: 5000)'
)
parser.add_argument(
'--debug',
action='store_true',
help='Enable debug mode'
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Enable verbose logging'
)
args = parser.parse_args()
# Setup logging
setup_logging(args.verbose)
# Create and run the web server
print(f"""
╔═══════════════════════════════════════════════════════════╗
║ MiniProfiler ║
║ Real-time Embedded Profiling Tool ║
╚═══════════════════════════════════════════════════════════╝
Starting web server...
Host: {args.host}
Port: {args.port}
Open your browser and navigate to:
http://localhost:{args.port}
Press Ctrl+C to stop the server.
""")
try:
server = create_app(args.host, args.port)
server.run(debug=args.debug)
except KeyboardInterrupt:
print("\n\nShutting down gracefully...")
sys.exit(0)
except Exception as e:
print(f"\nError: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,280 @@
"""Protocol definitions and packet structures for MiniProfiler.
This module defines the binary protocol used for communication between
the embedded device and the host application.
"""
import struct
from enum import IntEnum
from dataclasses import dataclass
from typing import List, Optional
import crc
class Command(IntEnum):
"""Commands sent from host to embedded device."""
START_PROFILING = 0x01
STOP_PROFILING = 0x02
GET_STATUS = 0x03
RESET_BUFFERS = 0x04
GET_METADATA = 0x05
SET_CONFIG = 0x06
class ResponseType(IntEnum):
"""Response types from embedded device to host."""
ACK = 0x01
NACK = 0x02
METADATA = 0x03
STATUS = 0x04
PROFILE_DATA = 0x05
# Protocol constants
COMMAND_HEADER = 0x55
RESPONSE_HEADER = 0xAA55
RESPONSE_END = 0x0A
PROTOCOL_VERSION = 0x01
@dataclass
class ProfileRecord:
"""A single profiling record from the embedded device.
Attributes:
func_addr: Function address (from instrumentation)
entry_time: Entry timestamp in microseconds
duration_us: Function duration in microseconds
depth: Call stack depth
"""
func_addr: int
entry_time: int
duration_us: int
depth: int
@classmethod
def from_bytes(cls, data: bytes) -> 'ProfileRecord':
"""Parse a ProfileRecord from binary data.
Format: <I (func_addr) <I (entry_time) <I (duration_us) <H (depth)
Total: 14 bytes
"""
if len(data) < 14:
raise ValueError(f"Invalid ProfileRecord size: {len(data)} bytes")
func_addr, entry_time, duration_us, depth = struct.unpack('<IIIH', data[:14])
return cls(func_addr, entry_time, duration_us, depth)
def to_bytes(self) -> bytes:
"""Serialize ProfileRecord to binary format."""
return struct.pack('<IIIH', self.func_addr, self.entry_time,
self.duration_us, self.depth)
@dataclass
class Metadata:
"""Metadata packet sent by embedded device at startup.
Attributes:
mcu_clock_hz: MCU clock frequency in Hz
timer_freq: Timer frequency in Hz
elf_build_id: CRC32 of .text section for version matching
fw_version: Firmware version string
"""
mcu_clock_hz: int
timer_freq: int
elf_build_id: int
fw_version: str
@classmethod
def from_bytes(cls, data: bytes) -> 'Metadata':
"""Parse Metadata from binary data.
Format: <I (mcu_clock) <I (timer_freq) <I (build_id) 16s (fw_version)
Total: 28 bytes
"""
if len(data) < 28:
raise ValueError(f"Invalid Metadata size: {len(data)} bytes")
mcu_clock_hz, timer_freq, elf_build_id, fw_version_bytes = struct.unpack(
'<III16s', data[:28]
)
fw_version = fw_version_bytes.decode('utf-8').rstrip('\x00')
return cls(mcu_clock_hz, timer_freq, elf_build_id, fw_version)
def to_bytes(self) -> bytes:
"""Serialize Metadata to binary format."""
fw_version_bytes = self.fw_version.encode('utf-8')[:16].ljust(16, b'\x00')
return struct.pack('<III16s', self.mcu_clock_hz, self.timer_freq,
self.elf_build_id, fw_version_bytes)
@dataclass
class StatusInfo:
"""Status information from embedded device.
Attributes:
is_profiling: Whether profiling is currently active
buffer_overflows: Number of buffer overflow events
records_captured: Total number of records captured
buffer_usage_percent: Current buffer usage percentage
"""
is_profiling: bool
buffer_overflows: int
records_captured: int
buffer_usage_percent: int
@classmethod
def from_bytes(cls, data: bytes) -> 'StatusInfo':
"""Parse StatusInfo from binary data.
Format: <B (is_profiling) <I (overflows) <I (records) <B (buffer_pct)
Total: 10 bytes
"""
if len(data) < 10:
raise ValueError(f"Invalid StatusInfo size: {len(data)} bytes")
is_profiling, buffer_overflows, records_captured, buffer_usage_percent = \
struct.unpack('<BIIB', data[:10])
return cls(bool(is_profiling), buffer_overflows, records_captured,
buffer_usage_percent)
class CommandPacket:
"""Command packet sent from host to embedded device."""
def __init__(self, cmd: Command, payload: bytes = b''):
"""Create a command packet.
Args:
cmd: Command type
payload: Optional payload (max 8 bytes)
"""
if len(payload) > 8:
raise ValueError("Command payload cannot exceed 8 bytes")
self.cmd = cmd
self.payload = payload.ljust(8, b'\x00')
def to_bytes(self) -> bytes:
"""Serialize command packet to binary format.
Format: <B (header) <B (cmd) <B (payload_len) 8s (payload) <B (checksum)
Total: 12 bytes
"""
payload_len = len(self.payload.rstrip(b'\x00'))
data = struct.pack('<BBB8s', COMMAND_HEADER, self.cmd, payload_len, self.payload)
checksum = sum(data) & 0xFF
return data + struct.pack('<B', checksum)
class ResponsePacket:
"""Response packet from embedded device to host."""
def __init__(self, response_type: ResponseType, payload: bytes):
"""Create a response packet.
Args:
response_type: Type of response
payload: Response payload
"""
self.response_type = response_type
self.payload = payload
@staticmethod
def calculate_crc16(data: bytes) -> int:
"""Calculate CRC16-CCITT for data validation."""
calculator = crc.Calculator(crc.Crc16.CCITT)
return calculator.checksum(data)
def to_bytes(self) -> bytes:
"""Serialize response packet to binary format.
Format: <H (header) <B (type) <H (payload_len) payload <H (crc16) <B (end)
"""
payload_len = len(self.payload)
header_data = struct.pack('<HBH', RESPONSE_HEADER, self.response_type,
payload_len)
data = header_data + self.payload
crc16 = self.calculate_crc16(data)
return data + struct.pack('<HB', crc16, RESPONSE_END)
@classmethod
def from_bytes(cls, data: bytes) -> Optional['ResponsePacket']:
"""Parse response packet from binary data.
Returns:
ResponsePacket if valid, None otherwise
"""
if len(data) < 8: # Minimum packet size
return None
# Parse header
header, response_type, payload_len = struct.unpack('<HBH', data[:5])
if header != RESPONSE_HEADER:
raise ValueError(f"Invalid response header: 0x{header:04X}")
# Check if we have enough data
total_len = 5 + payload_len + 3 # header + payload + crc + end
if len(data) < total_len:
return None
# Extract payload
payload = data[5:5+payload_len]
# Verify CRC
crc16_expected = struct.unpack('<H', data[5+payload_len:5+payload_len+2])[0]
crc16_actual = cls.calculate_crc16(data[:5+payload_len])
if crc16_expected != crc16_actual:
raise ValueError(f"CRC mismatch: expected 0x{crc16_expected:04X}, "
f"got 0x{crc16_actual:04X}")
# Verify end marker
end_marker = data[5+payload_len+2]
if end_marker != RESPONSE_END:
raise ValueError(f"Invalid end marker: 0x{end_marker:02X}")
return cls(ResponseType(response_type), payload)
def parse_payload(self) -> object:
"""Parse the payload based on response type.
Returns:
Parsed payload object (Metadata, StatusInfo, List[ProfileRecord], etc.)
"""
if self.response_type == ResponseType.METADATA:
return Metadata.from_bytes(self.payload)
elif self.response_type == ResponseType.STATUS:
return StatusInfo.from_bytes(self.payload)
elif self.response_type == ResponseType.PROFILE_DATA:
# First byte is protocol version
if len(self.payload) < 3:
return []
version = self.payload[0]
if version != PROTOCOL_VERSION:
raise ValueError(f"Unsupported protocol version: {version}")
record_count = struct.unpack('<H', self.payload[1:3])[0]
records = []
offset = 3
for _ in range(record_count):
if offset + 14 > len(self.payload):
break
record = ProfileRecord.from_bytes(self.payload[offset:offset+14])
records.append(record)
offset += 14
return records
elif self.response_type == ResponseType.ACK:
return True
elif self.response_type == ResponseType.NACK:
return False
return self.payload

View File

@@ -0,0 +1,267 @@
"""Serial communication module for MiniProfiler.
Handles UART communication with the embedded device, including
sending commands and receiving profiling data.
"""
import serial
import threading
import time
import logging
from typing import Callable, Optional, List
from queue import Queue
from .protocol import (
Command, CommandPacket, ResponsePacket, ResponseType,
ProfileRecord, Metadata, StatusInfo
)
logger = logging.getLogger(__name__)
class SerialReader:
"""Manages serial communication with the embedded profiling device."""
def __init__(self, port: str, baudrate: int = 115200, timeout: float = 1.0):
"""Initialize serial reader.
Args:
port: Serial port name (e.g., '/dev/ttyUSB0', 'COM3')
baudrate: Baud rate (default: 115200)
timeout: Read timeout in seconds
"""
self.port = port
self.baudrate = baudrate
self.timeout = timeout
self.serial: Optional[serial.Serial] = None
self.running = False
self.read_thread: Optional[threading.Thread] = None
self.command_queue = Queue()
# Callbacks
self.on_profile_data: Optional[Callable[[List[ProfileRecord]], None]] = None
self.on_metadata: Optional[Callable[[Metadata], None]] = None
self.on_status: Optional[Callable[[StatusInfo], None]] = None
self.on_error: Optional[Callable[[Exception], None]] = None
def connect(self) -> bool:
"""Open the serial port connection.
Returns:
True if connection successful, False otherwise
"""
try:
self.serial = serial.Serial(
port=self.port,
baudrate=self.baudrate,
timeout=self.timeout,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE
)
logger.info(f"Connected to {self.port} at {self.baudrate} baud")
return True
except serial.SerialException as e:
logger.error(f"Failed to connect to {self.port}: {e}")
if self.on_error:
self.on_error(e)
return False
def disconnect(self):
"""Close the serial port connection."""
if self.serial and self.serial.is_open:
self.serial.close()
logger.info(f"Disconnected from {self.port}")
def send_command(self, cmd: Command, payload: bytes = b'') -> bool:
"""Send a command to the embedded device.
Args:
cmd: Command to send
payload: Optional command payload
Returns:
True if command sent successfully, False otherwise
"""
if not self.serial or not self.serial.is_open:
logger.error("Serial port not open")
return False
try:
packet = CommandPacket(cmd, payload)
data = packet.to_bytes()
self.serial.write(data)
logger.debug(f"Sent command: {cmd.name}")
return True
except Exception as e:
logger.error(f"Failed to send command {cmd.name}: {e}")
if self.on_error:
self.on_error(e)
return False
def start_profiling(self) -> bool:
"""Send START_PROFILING command."""
return self.send_command(Command.START_PROFILING)
def stop_profiling(self) -> bool:
"""Send STOP_PROFILING command."""
return self.send_command(Command.STOP_PROFILING)
def get_metadata(self) -> bool:
"""Request metadata from the device."""
return self.send_command(Command.GET_METADATA)
def get_status(self) -> bool:
"""Request status from the device."""
return self.send_command(Command.GET_STATUS)
def reset_buffers(self) -> bool:
"""Send RESET_BUFFERS command."""
return self.send_command(Command.RESET_BUFFERS)
def _read_packet(self) -> Optional[ResponsePacket]:
"""Read a response packet from the serial port.
Returns:
ResponsePacket if valid packet received, None otherwise
"""
if not self.serial or not self.serial.is_open:
return None
buffer = bytearray()
try:
# Search for header (0xAA55)
while self.running:
byte = self.serial.read(1)
if not byte:
continue
buffer.append(byte[0])
# Check for header
if len(buffer) >= 2:
header = (buffer[-2] << 8) | buffer[-1]
if header == 0xAA55:
# Found header, read rest of packet
buffer = bytearray([0xAA, 0x55])
break
# Keep only last byte for next iteration
if len(buffer) > 1:
buffer = bytearray([buffer[-1]])
if not self.running:
return None
# Read type and length (3 bytes)
header_rest = self.serial.read(3)
if len(header_rest) < 3:
return None
buffer.extend(header_rest)
# Extract payload length
payload_len = (header_rest[2] << 8) | header_rest[1]
# Read payload + CRC (2 bytes) + end marker (1 byte)
remaining = payload_len + 3
remaining_data = self.serial.read(remaining)
if len(remaining_data) < remaining:
logger.warning(f"Incomplete packet: expected {remaining}, got {len(remaining_data)}")
return None
buffer.extend(remaining_data)
# Parse packet
packet = ResponsePacket.from_bytes(bytes(buffer))
return packet
except Exception as e:
logger.error(f"Error reading packet: {e}")
if self.on_error:
self.on_error(e)
return None
def _reader_thread(self):
"""Background thread for reading serial data."""
logger.info("Serial reader thread started")
while self.running:
packet = self._read_packet()
if not packet:
continue
try:
# Parse and dispatch based on response type
if packet.response_type == ResponseType.PROFILE_DATA:
records = packet.parse_payload()
if self.on_profile_data and isinstance(records, list):
self.on_profile_data(records)
elif packet.response_type == ResponseType.METADATA:
metadata = packet.parse_payload()
if self.on_metadata and isinstance(metadata, Metadata):
self.on_metadata(metadata)
elif packet.response_type == ResponseType.STATUS:
status = packet.parse_payload()
if self.on_status and isinstance(status, StatusInfo):
self.on_status(status)
elif packet.response_type == ResponseType.ACK:
logger.debug("Received ACK")
elif packet.response_type == ResponseType.NACK:
logger.warning("Received NACK")
except Exception as e:
logger.error(f"Error processing packet: {e}")
if self.on_error:
self.on_error(e)
logger.info("Serial reader thread stopped")
def start_reading(self) -> bool:
"""Start background thread to read serial data.
Returns:
True if thread started successfully, False otherwise
"""
if self.running:
logger.warning("Reader thread already running")
return False
if not self.serial or not self.serial.is_open:
logger.error("Serial port not open")
return False
self.running = True
self.read_thread = threading.Thread(target=self._reader_thread, daemon=True)
self.read_thread.start()
logger.info("Started serial reading thread")
return True
def stop_reading(self):
"""Stop the background reading thread."""
if not self.running:
return
logger.info("Stopping serial reader thread...")
self.running = False
if self.read_thread:
self.read_thread.join(timeout=2.0)
if self.read_thread.is_alive():
logger.warning("Reader thread did not stop cleanly")
else:
logger.info("Reader thread stopped")
def __enter__(self):
"""Context manager entry."""
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.stop_reading()
self.disconnect()

View File

@@ -0,0 +1,205 @@
"""Symbol resolution using ELF/DWARF debug information.
This module resolves function addresses to human-readable names,
file locations, and line numbers using the ELF symbol table and
DWARF debugging information.
"""
import logging
from typing import Dict, Optional, Tuple
from pathlib import Path
from elftools.elf.elffile import ELFFile
from elftools.dwarf.descriptions import describe_form_class
from elftools.dwarf.die import DIE
logger = logging.getLogger(__name__)
class SymbolInfo:
"""Information about a symbol."""
def __init__(self, name: str, file: str = "", line: int = 0, size: int = 0):
"""Initialize symbol information.
Args:
name: Function or symbol name
file: Source file path
line: Line number in source file
size: Symbol size in bytes
"""
self.name = name
self.file = file
self.line = line
self.size = size
def __repr__(self) -> str:
if self.file and self.line:
return f"{self.name} ({self.file}:{self.line})"
return self.name
class Symbolizer:
"""Resolves addresses to symbol names using ELF/DWARF information."""
def __init__(self, elf_path: str):
"""Initialize symbolizer with an ELF file.
Args:
elf_path: Path to the ELF file with debug symbols
"""
self.elf_path = Path(elf_path)
self.symbols: Dict[int, SymbolInfo] = {}
self.loaded = False
if self.elf_path.exists():
self._load_symbols()
else:
logger.warning(f"ELF file not found: {elf_path}")
def _load_symbols(self):
"""Load symbols from the ELF file."""
try:
with open(self.elf_path, 'rb') as f:
elffile = ELFFile(f)
# Load symbol table
self._load_symbol_table(elffile)
# Load DWARF debug info for line numbers
if elffile.has_dwarf_info():
self._load_dwarf_info(elffile)
self.loaded = True
logger.info(f"Loaded {len(self.symbols)} symbols from {self.elf_path}")
except Exception as e:
logger.error(f"Failed to load symbols from {self.elf_path}: {e}")
self.loaded = False
def _load_symbol_table(self, elffile: ELFFile):
"""Load function symbols from the symbol table.
Args:
elffile: Parsed ELF file object
"""
symtab = elffile.get_section_by_name('.symtab')
if not symtab:
logger.warning("No symbol table found in ELF file")
return
for symbol in symtab.iter_symbols():
# Only interested in function symbols
if symbol['st_info']['type'] == 'STT_FUNC':
addr = symbol['st_value']
name = symbol.name
size = symbol['st_size']
if addr and name:
self.symbols[addr] = SymbolInfo(name, size=size)
logger.debug(f"Loaded {len(self.symbols)} function symbols from symbol table")
def _load_dwarf_info(self, elffile: ELFFile):
"""Load DWARF debug information for file/line mappings.
Args:
elffile: Parsed ELF file object
"""
dwarfinfo = elffile.get_dwarf_info()
# Process line number information
for CU in dwarfinfo.iter_CUs():
lineprog = dwarfinfo.line_program_for_CU(CU)
if not lineprog:
continue
# Get the file table
file_entries = lineprog.header['file_entry']
# Process line program entries
prevstate = None
for entry in lineprog.get_entries():
if entry.state is None:
continue
# Look for end of sequence or new addresses
state = entry.state
if prevstate and state.address != prevstate.address:
addr = prevstate.address
file_index = prevstate.file - 1
if 0 <= file_index < len(file_entries):
file_entry = file_entries[file_index]
filename = file_entry.name.decode('utf-8') if isinstance(
file_entry.name, bytes) else file_entry.name
# Update existing symbol or create new one
if addr in self.symbols:
self.symbols[addr].file = filename
self.symbols[addr].line = prevstate.line
else:
# Create placeholder symbol for addresses without symbol table entry
self.symbols[addr] = SymbolInfo(
f"func_0x{addr:08x}",
file=filename,
line=prevstate.line
)
prevstate = state
logger.debug("Loaded DWARF debug information")
def resolve(self, addr: int) -> SymbolInfo:
"""Resolve an address to symbol information.
Args:
addr: Function address to resolve
Returns:
SymbolInfo object (may contain placeholder name if not found)
"""
# Exact match
if addr in self.symbols:
return self.symbols[addr]
# Try to find the containing function (address within function range)
for sym_addr, sym_info in self.symbols.items():
if sym_addr <= addr < sym_addr + sym_info.size:
return sym_info
# Not found - return placeholder
return SymbolInfo(f"unknown_0x{addr:08x}")
def resolve_name(self, addr: int) -> str:
"""Resolve an address to a function name.
Args:
addr: Function address
Returns:
Function name string
"""
return self.resolve(addr).name
def resolve_location(self, addr: int) -> str:
"""Resolve an address to a file:line location string.
Args:
addr: Function address
Returns:
Location string in format "file:line" or empty string
"""
info = self.resolve(addr)
if info.file and info.line:
return f"{info.file}:{info.line}"
return ""
def get_all_symbols(self) -> Dict[int, SymbolInfo]:
"""Get all loaded symbols.
Returns:
Dictionary mapping addresses to SymbolInfo objects
"""
return self.symbols.copy()

View File

@@ -0,0 +1,315 @@
"""Flask web server for MiniProfiler visualization.
Provides a web interface for real-time profiling visualization including
flame graphs, timelines, and statistics tables.
"""
import logging
from flask import Flask, render_template, jsonify, request
from flask_socketio import SocketIO, emit
from typing import Optional
import threading
from .serial_reader import SerialReader
from .analyzer import ProfileAnalyzer
from .symbolizer import Symbolizer
from .protocol import ProfileRecord, Metadata, StatusInfo
logger = logging.getLogger(__name__)
class ProfilerWebServer:
"""Web server for profiler visualization and control."""
def __init__(self, host: str = '0.0.0.0', port: int = 5000):
"""Initialize the web server.
Args:
host: Host address to bind to
port: Port number to listen on
"""
self.host = host
self.port = port
# Initialize Flask app
self.app = Flask(__name__,
template_folder='../web/templates',
static_folder='../web/static')
self.app.config['SECRET_KEY'] = 'miniprofiler-secret-key'
# Initialize SocketIO
self.socketio = SocketIO(self.app, cors_allowed_origins="*")
# Profiler components
self.serial_reader: Optional[SerialReader] = None
self.analyzer = ProfileAnalyzer()
self.symbolizer: Optional[Symbolizer] = None
self.metadata: Optional[Metadata] = None
# State
self.is_connected = False
self.is_profiling = False
# Setup routes
self._setup_routes()
self._setup_socketio_handlers()
def _setup_routes(self):
"""Setup Flask HTTP routes."""
@self.app.route('/')
def index():
"""Main page."""
return render_template('index.html')
@self.app.route('/api/status')
def status():
"""Get server status."""
return jsonify({
'connected': self.is_connected,
'profiling': self.is_profiling,
'has_data': len(self.analyzer.records) > 0,
'record_count': len(self.analyzer.records)
})
@self.app.route('/api/summary')
def summary():
"""Get profiling summary statistics."""
return jsonify(self.analyzer.get_summary())
@self.app.route('/api/flamegraph')
def flamegraph():
"""Get flame graph data."""
return jsonify(self.analyzer.to_flamegraph_json())
@self.app.route('/api/timeline')
def timeline():
"""Get timeline data."""
return jsonify(self.analyzer.to_timeline_json())
@self.app.route('/api/statistics')
def statistics():
"""Get statistics table data."""
return jsonify(self.analyzer.to_statistics_json())
def _setup_socketio_handlers(self):
"""Setup SocketIO event handlers."""
@self.socketio.on('connect')
def handle_connect():
"""Handle client connection."""
logger.info("Client connected")
emit('status', {
'connected': self.is_connected,
'profiling': self.is_profiling
})
@self.socketio.on('disconnect')
def handle_disconnect():
"""Handle client disconnection."""
logger.info("Client disconnected")
@self.socketio.on('connect_serial')
def handle_connect_serial(data):
"""Connect to serial port.
Args:
data: Dict with 'port' and optional 'baudrate'
"""
port = data.get('port')
baudrate = data.get('baudrate', 115200)
elf_path = data.get('elf_path', None)
if not port:
emit('error', {'message': 'No port specified'})
return
try:
# Load symbolizer if ELF path provided
if elf_path:
self.symbolizer = Symbolizer(elf_path)
self.analyzer.symbolizer = self.symbolizer
# Create serial reader
self.serial_reader = SerialReader(port, baudrate)
# Set up callbacks
self.serial_reader.on_profile_data = self._on_profile_data
self.serial_reader.on_metadata = self._on_metadata
self.serial_reader.on_status = self._on_status
self.serial_reader.on_error = self._on_error
# Connect
if self.serial_reader.connect():
self.serial_reader.start_reading()
self.is_connected = True
# Request metadata
self.serial_reader.get_metadata()
emit('connected', {'port': port, 'baudrate': baudrate})
logger.info(f"Connected to {port} at {baudrate} baud")
else:
emit('error', {'message': f'Failed to connect to {port}'})
except Exception as e:
logger.error(f"Error connecting to serial: {e}")
emit('error', {'message': str(e)})
@self.socketio.on('disconnect_serial')
def handle_disconnect_serial():
"""Disconnect from serial port."""
if self.serial_reader:
self.serial_reader.stop_reading()
self.serial_reader.disconnect()
self.serial_reader = None
self.is_connected = False
self.is_profiling = False
emit('disconnected', {})
logger.info("Disconnected from serial port")
@self.socketio.on('start_profiling')
def handle_start_profiling():
"""Start profiling on the device."""
if not self.serial_reader or not self.is_connected:
emit('error', {'message': 'Not connected to device'})
return
if self.serial_reader.start_profiling():
self.is_profiling = True
emit('profiling_started', {})
logger.info("Started profiling")
else:
emit('error', {'message': 'Failed to start profiling'})
@self.socketio.on('stop_profiling')
def handle_stop_profiling():
"""Stop profiling on the device."""
if not self.serial_reader or not self.is_connected:
emit('error', {'message': 'Not connected to device'})
return
if self.serial_reader.stop_profiling():
self.is_profiling = False
emit('profiling_stopped', {})
logger.info("Stopped profiling")
else:
emit('error', {'message': 'Failed to stop profiling'})
@self.socketio.on('clear_data')
def handle_clear_data():
"""Clear all profiling data."""
self.analyzer.clear()
emit('data_cleared', {})
self._emit_data_update()
logger.info("Cleared profiling data")
@self.socketio.on('reset_buffers')
def handle_reset_buffers():
"""Reset device buffers."""
if not self.serial_reader or not self.is_connected:
emit('error', {'message': 'Not connected to device'})
return
if self.serial_reader.reset_buffers():
emit('buffers_reset', {})
logger.info("Reset device buffers")
else:
emit('error', {'message': 'Failed to reset buffers'})
def _on_profile_data(self, records):
"""Callback for receiving profile data.
Args:
records: List of ProfileRecord objects
"""
logger.debug(f"Received {len(records)} profile records")
self.analyzer.add_records(records)
self._emit_data_update()
def _on_metadata(self, metadata: Metadata):
"""Callback for receiving metadata.
Args:
metadata: Metadata object
"""
logger.info(f"Received metadata: {metadata.fw_version}, "
f"MCU: {metadata.mcu_clock_hz / 1e6:.1f} MHz")
self.metadata = metadata
self.socketio.emit('metadata', {
'fw_version': metadata.fw_version,
'mcu_clock_hz': metadata.mcu_clock_hz,
'timer_freq': metadata.timer_freq,
'build_id': f"0x{metadata.elf_build_id:08X}"
})
def _on_status(self, status: StatusInfo):
"""Callback for receiving status updates.
Args:
status: StatusInfo object
"""
logger.debug(f"Device status: profiling={status.is_profiling}, "
f"records={status.records_captured}, "
f"overflows={status.buffer_overflows}")
self.socketio.emit('device_status', {
'is_profiling': status.is_profiling,
'records_captured': status.records_captured,
'buffer_overflows': status.buffer_overflows,
'buffer_usage': status.buffer_usage_percent
})
def _on_error(self, error: Exception):
"""Callback for serial errors.
Args:
error: Exception that occurred
"""
logger.error(f"Serial error: {error}")
self.socketio.emit('error', {'message': str(error)})
def _emit_data_update(self):
"""Emit updated profiling data to all clients."""
try:
# Send summary
summary = self.analyzer.get_summary()
self.socketio.emit('summary_update', summary)
# Send flamegraph data
flamegraph_data = self.analyzer.to_flamegraph_json()
self.socketio.emit('flamegraph_update', flamegraph_data)
# Send statistics
stats_data = self.analyzer.to_statistics_json()
self.socketio.emit('statistics_update', stats_data)
# Send timeline data (can be large, so only send periodically)
if len(self.analyzer.records) % 50 == 0: # Every 50 records
timeline_data = self.analyzer.to_timeline_json()
self.socketio.emit('timeline_update', timeline_data)
except Exception as e:
logger.error(f"Error emitting data update: {e}")
def run(self, debug: bool = False):
"""Run the web server.
Args:
debug: Enable debug mode
"""
logger.info(f"Starting web server on {self.host}:{self.port}")
self.socketio.run(self.app, host=self.host, port=self.port, debug=debug)
def create_app(host: str = '0.0.0.0', port: int = 5000) -> ProfilerWebServer:
"""Create and configure the profiler web server.
Args:
host: Host address
port: Port number
Returns:
Configured ProfilerWebServer instance
"""
return ProfilerWebServer(host, port)

7
host/requirements.txt Normal file
View File

@@ -0,0 +1,7 @@
Flask>=3.0.0
Flask-SocketIO>=5.3.0
pyserial>=3.5
pyelftools>=0.29
crc>=6.1.1
python-socketio>=5.10.0
eventlet>=0.33.3

13
host/run.py Executable file
View File

@@ -0,0 +1,13 @@
#!/usr/bin/env python3
"""Quick start script for MiniProfiler."""
import sys
import os
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from miniprofiler.cli import main
if __name__ == '__main__':
main()

51
host/setup.py Normal file
View File

@@ -0,0 +1,51 @@
"""Setup script for MiniProfiler host application."""
from setuptools import setup, find_packages
with open("../README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
setup(
name="miniprofiler",
version="0.1.0",
author="MiniProfiler Contributors",
description="Host application for embedded STM32 profiling",
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/yourusername/miniprofiler",
packages=find_packages(),
classifiers=[
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Topic :: Software Development :: Embedded Systems",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
],
python_requires=">=3.8",
install_requires=[
"Flask>=3.0.0",
"Flask-SocketIO>=5.3.0",
"pyserial>=3.5",
"pyelftools>=0.29",
"crc>=6.1.1",
"python-socketio>=5.10.0",
"eventlet>=0.33.3",
],
entry_points={
"console_scripts": [
"miniprofiler=miniprofiler.cli:main",
],
},
include_package_data=True,
package_data={
"miniprofiler": [
"../web/templates/*.html",
"../web/static/css/*.css",
"../web/static/js/*.js",
],
},
)

1
host/tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Test utilities for MiniProfiler."""

View File

@@ -0,0 +1,236 @@
"""Sample data generator for testing the profiler without hardware.
Generates realistic profiling data that mimics an embedded application
with nested function calls, varying execution times, and typical patterns.
"""
import random
from typing import List
from miniprofiler.protocol import ProfileRecord, Metadata, ResponsePacket, ResponseType
import struct
# Sample function addresses (simulating typical embedded firmware)
FUNCTIONS = {
0x08000100: "main",
0x08000200: "app_init",
0x08000220: "peripheral_init",
0x08000240: "clock_config",
0x08000300: "app_loop",
0x08000320: "process_sensors",
0x08000340: "read_temperature",
0x08000360: "read_pressure",
0x08000380: "process_data",
0x080003A0: "calculate_average",
0x080003C0: "apply_filter",
0x08000400: "update_display",
0x08000420: "format_string",
0x08000440: "send_uart",
0x08000500: "handle_interrupt",
0x08000520: "gpio_callback",
}
def generate_metadata() -> Metadata:
"""Generate sample metadata packet."""
return Metadata(
mcu_clock_hz=168_000_000, # 168 MHz (typical STM32F4)
timer_freq=1_000_000, # 1 MHz timer
elf_build_id=0xDEADBEEF,
fw_version="v1.0.0-test"
)
def generate_nested_calls(
start_time: int,
depth: int = 0,
max_depth: int = 5
) -> tuple[List[ProfileRecord], int]:
"""Generate nested function calls recursively.
Args:
start_time: Starting timestamp in microseconds
depth: Current call stack depth
max_depth: Maximum recursion depth
Returns:
Tuple of (list of ProfileRecords, end_time)
"""
records = []
current_time = start_time
# Select random functions based on depth
if depth == 0:
func_addr = 0x08000300 # app_loop
num_children = random.randint(2, 4)
elif depth == 1:
func_addr = random.choice([0x08000320, 0x08000380, 0x08000400])
num_children = random.randint(1, 3)
else:
func_addr = random.choice(list(FUNCTIONS.keys())[depth * 2:(depth + 1) * 2 + 4])
num_children = random.randint(0, 2) if depth < max_depth else 0
entry_time = current_time
current_time += random.randint(1, 10) # Entry overhead
# Generate child calls
children_records = []
if num_children > 0 and depth < max_depth:
for _ in range(num_children):
child_records, current_time = generate_nested_calls(
current_time, depth + 1, max_depth
)
children_records.extend(child_records)
current_time += random.randint(5, 20) # Gap between children
# Add some self-time for this function
self_time = random.randint(10, 200)
current_time += self_time
exit_time = current_time
duration = exit_time - entry_time
# Create record for this function
record = ProfileRecord(
func_addr=func_addr,
entry_time=entry_time,
duration_us=duration,
depth=depth
)
records.append(record)
records.extend(children_records)
return records, exit_time
def generate_sample_profile_data(
num_iterations: int = 10,
time_per_iteration: int = 10000 # 10ms per iteration
) -> List[ProfileRecord]:
"""Generate sample profiling data simulating multiple loop iterations.
Args:
num_iterations: Number of main loop iterations
time_per_iteration: Approximate time per iteration in microseconds
Returns:
List of ProfileRecord objects
"""
all_records = []
current_time = 0
# Generate initialization sequence (runs once)
init_records = [
ProfileRecord(0x08000100, current_time, 5000, 0), # main
ProfileRecord(0x08000200, current_time + 100, 1000, 1), # app_init
ProfileRecord(0x08000220, current_time + 150, 300, 2), # peripheral_init
ProfileRecord(0x08000240, current_time + 500, 400, 2), # clock_config
]
all_records.extend(init_records)
current_time += 5000
# Generate main loop iterations
for iteration in range(num_iterations):
records, end_time = generate_nested_calls(
current_time,
depth=0,
max_depth=4
)
all_records.extend(records)
current_time = end_time + random.randint(50, 200) # Idle time
return all_records
def generate_profile_data_packet(records: List[ProfileRecord]) -> bytes:
"""Generate a binary PROFILE_DATA response packet.
Args:
records: List of ProfileRecord objects
Returns:
Binary packet data
"""
# Build payload: version (1B) + count (2B) + records
payload = struct.pack('<BH', 0x01, len(records))
for record in records:
payload += record.to_bytes()
# Create response packet
packet = ResponsePacket(ResponseType.PROFILE_DATA, payload)
return packet.to_bytes()
def generate_metadata_packet() -> bytes:
"""Generate a binary METADATA response packet.
Returns:
Binary packet data
"""
metadata = generate_metadata()
packet = ResponsePacket(ResponseType.METADATA, metadata.to_bytes())
return packet.to_bytes()
def save_sample_data(filename: str, num_iterations: int = 50):
"""Generate and save sample profiling data to a file.
Args:
filename: Output filename
num_iterations: Number of loop iterations to generate
"""
records = generate_sample_profile_data(num_iterations)
with open(filename, 'wb') as f:
# Write metadata packet
f.write(generate_metadata_packet())
# Write profile data in chunks (simulate streaming)
chunk_size = 20
for i in range(0, len(records), chunk_size):
chunk = records[i:i + chunk_size]
f.write(generate_profile_data_packet(chunk))
print(f"Generated {len(records)} records in {filename}")
if __name__ == "__main__":
# Generate sample data
records = generate_sample_profile_data(num_iterations=20)
print(f"Generated {len(records)} profiling records")
print(f"\nFirst 10 records:")
for i, record in enumerate(records[:10]):
func_name = FUNCTIONS.get(record.func_addr, f"0x{record.func_addr:08x}")
print(f" [{i}] {func_name:20s} @ {record.entry_time:8d}us, "
f"duration: {record.duration_us:6d}us, depth: {record.depth}")
# Save to file
save_sample_data("sample_profile_data.bin", num_iterations=50)
# Also generate JSON for testing web interface
import json
from miniprofiler.analyzer import ProfileAnalyzer
analyzer = ProfileAnalyzer()
analyzer.add_records(records)
# Generate flamegraph data
flamegraph_data = analyzer.to_flamegraph_json()
with open("sample_flamegraph.json", 'w') as f:
json.dump(flamegraph_data, f, indent=2)
print(f"\nGenerated sample_flamegraph.json")
# Generate statistics data
stats_data = analyzer.to_statistics_json()
with open("sample_statistics.json", 'w') as f:
json.dump(stats_data, f, indent=2)
print(f"Generated sample_statistics.json")
# Generate timeline data
timeline_data = analyzer.to_timeline_json()
with open("sample_timeline.json", 'w') as f:
json.dump(timeline_data, f, indent=2)
print(f"Generated sample_timeline.json")

View File

@@ -0,0 +1,282 @@
/* MiniProfiler Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #1e1e1e;
color: #d4d4d4;
line-height: 1.6;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* Header */
header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #3a3a3a;
}
header h1 {
font-size: 2.5em;
color: #569cd6;
margin-bottom: 5px;
}
.subtitle {
color: #9cdcfe;
font-size: 0.9em;
}
/* Control Panel */
.control-panel {
background: #252526;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.connection-controls, .profiling-controls {
display: flex;
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
}
input[type="text"], input[type="number"] {
flex: 1;
min-width: 150px;
padding: 10px;
background: #3c3c3c;
border: 1px solid #555;
border-radius: 4px;
color: #d4d4d4;
font-size: 14px;
}
input[type="text"]:focus, input[type="number"]:focus {
outline: none;
border-color: #569cd6;
}
button {
padding: 10px 20px;
background: #0e639c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
button:hover:not(:disabled) {
background: #1177bb;
}
button:disabled {
background: #3a3a3a;
color: #777;
cursor: not-allowed;
}
button#stop-btn {
background: #d16969;
}
button#stop-btn:hover:not(:disabled) {
background: #e67373;
}
button#clear-btn, button#reset-btn {
background: #4e4e4e;
}
button#clear-btn:hover:not(:disabled), button#reset-btn:hover:not(:disabled) {
background: #5e5e5e;
}
/* Status Display */
.status-display {
display: flex;
align-items: center;
gap: 15px;
padding-top: 10px;
border-top: 1px solid #3a3a3a;
}
.status-indicator {
font-size: 1.5em;
color: #6e6e6e;
}
.status-indicator.connected {
color: #4ec9b0;
}
.status-indicator.profiling {
color: #ce9178;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Metadata Panel */
.metadata-panel, .summary-panel {
background: #252526;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.metadata-panel h3, .summary-panel h3 {
color: #9cdcfe;
margin-bottom: 10px;
font-size: 1.1em;
}
#metadata-content, #summary-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
font-size: 0.9em;
}
#summary-content p {
margin: 5px 0;
}
/* Tabs */
.tabs {
display: flex;
gap: 5px;
margin-bottom: 0;
}
.tab-button {
padding: 12px 24px;
background: #2d2d30;
border: none;
border-radius: 8px 8px 0 0;
color: #9cdcfe;
cursor: pointer;
transition: background 0.3s;
}
.tab-button:hover {
background: #3e3e42;
}
.tab-button.active {
background: #252526;
color: #569cd6;
}
/* Tab Content */
.tab-content {
display: none;
background: #252526;
padding: 20px;
border-radius: 0 8px 8px 8px;
min-height: 500px;
}
.tab-content.active {
display: block;
}
/* Flame Graph */
#flamegraph {
width: 100%;
min-height: 500px;
}
/* Override d3-flamegraph default styles for dark theme */
.d3-flame-graph rect {
stroke: #1e1e1e;
stroke-width: 1;
}
.d3-flame-graph text {
fill: #fff;
font-size: 12px;
}
.d3-flame-graph .label {
pointer-events: none;
}
/* Timeline */
#timeline {
width: 100%;
min-height: 500px;
}
/* Statistics Table */
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
thead {
background: #2d2d30;
}
th {
padding: 12px;
text-align: left;
color: #9cdcfe;
font-weight: 600;
border-bottom: 2px solid #3a3a3a;
}
td {
padding: 10px 12px;
border-bottom: 1px solid #3a3a3a;
}
tbody tr:hover {
background: #2d2d30;
}
tbody tr:nth-child(even) {
background: #1e1e1e;
}
/* Responsive */
@media (max-width: 768px) {
.connection-controls, .profiling-controls {
flex-direction: column;
}
input[type="text"], input[type="number"], button {
width: 100%;
}
.tabs {
flex-direction: column;
}
.tab-button {
border-radius: 4px;
}
}

340
host/web/static/js/app.js Normal file
View File

@@ -0,0 +1,340 @@
// MiniProfiler Web Application
// Global state
let socket = null;
let isConnected = false;
let isProfiling = false;
let flamegraph = null;
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
initializeSocket();
initializeFlameGraph();
});
// Socket.IO Connection
function initializeSocket() {
socket = io();
socket.on('connect', function() {
console.log('Connected to server');
updateStatus('Connected to server', false);
});
socket.on('disconnect', function() {
console.log('Disconnected from server');
updateStatus('Disconnected from server', false);
isConnected = false;
updateControlButtons();
});
socket.on('connected', function(data) {
console.log('Connected to serial:', data);
isConnected = true;
updateStatus(`Connected to ${data.port}`, true);
updateControlButtons();
document.getElementById('metadata-panel').style.display = 'block';
});
socket.on('disconnected', function() {
console.log('Disconnected from serial');
isConnected = false;
isProfiling = false;
updateStatus('Disconnected', false);
updateControlButtons();
document.getElementById('metadata-panel').style.display = 'none';
});
socket.on('profiling_started', function() {
console.log('Profiling started');
isProfiling = true;
updateStatus('Profiling active', true, true);
updateControlButtons();
});
socket.on('profiling_stopped', function() {
console.log('Profiling stopped');
isProfiling = false;
updateStatus('Profiling stopped', true);
updateControlButtons();
});
socket.on('metadata', function(data) {
console.log('Metadata received:', data);
displayMetadata(data);
});
socket.on('device_status', function(data) {
console.log('Device status:', data);
if (data.buffer_overflows > 0) {
console.warn(`Buffer overflows detected: ${data.buffer_overflows}`);
}
});
socket.on('summary_update', function(data) {
updateSummary(data);
});
socket.on('flamegraph_update', function(data) {
updateFlameGraph(data);
});
socket.on('statistics_update', function(data) {
updateStatistics(data);
});
socket.on('timeline_update', function(data) {
updateTimeline(data);
});
socket.on('error', function(data) {
console.error('Error:', data.message);
alert('Error: ' + data.message);
});
}
// Connection Control
function toggleConnection() {
if (isConnected) {
socket.emit('disconnect_serial');
document.getElementById('connect-btn').textContent = 'Connect';
} else {
const port = document.getElementById('serial-port').value;
const baudrate = parseInt(document.getElementById('baudrate').value);
const elfPath = document.getElementById('elf-path').value;
if (!port) {
alert('Please enter a serial port');
return;
}
socket.emit('connect_serial', {
port: port,
baudrate: baudrate,
elf_path: elfPath || null
});
document.getElementById('connect-btn').textContent = 'Disconnect';
}
}
function startProfiling() {
socket.emit('start_profiling');
}
function stopProfiling() {
socket.emit('stop_profiling');
}
function clearData() {
if (confirm('Clear all profiling data?')) {
socket.emit('clear_data');
}
}
function resetBuffers() {
socket.emit('reset_buffers');
}
// UI Updates
function updateStatus(text, connected, profiling = false) {
const statusText = document.getElementById('status-text');
const statusIndicator = document.getElementById('connection-status');
statusText.textContent = text;
statusIndicator.classList.remove('connected', 'profiling');
if (profiling) {
statusIndicator.classList.add('profiling');
} else if (connected) {
statusIndicator.classList.add('connected');
}
}
function updateControlButtons() {
document.getElementById('start-btn').disabled = !isConnected || isProfiling;
document.getElementById('stop-btn').disabled = !isConnected || !isProfiling;
document.getElementById('clear-btn').disabled = !isConnected;
document.getElementById('reset-btn').disabled = !isConnected;
}
function displayMetadata(data) {
const content = document.getElementById('metadata-content');
content.innerHTML = `
<div><strong>Firmware:</strong> ${data.fw_version}</div>
<div><strong>MCU Clock:</strong> ${(data.mcu_clock_hz / 1e6).toFixed(1)} MHz</div>
<div><strong>Timer Freq:</strong> ${(data.timer_freq / 1e6).toFixed(1)} MHz</div>
<div><strong>Build ID:</strong> ${data.build_id}</div>
`;
}
function updateSummary(data) {
const content = document.getElementById('summary-content');
if (data.total_records === 0) {
content.innerHTML = '<p>No profiling data available</p>';
return;
}
const totalTimeMs = (data.total_time_us / 1000).toFixed(2);
const hottestTimeMs = (data.hottest_time_us / 1000).toFixed(2);
content.innerHTML = `
<div><strong>Total Records:</strong> ${data.total_records}</div>
<div><strong>Unique Functions:</strong> ${data.total_functions}</div>
<div><strong>Total Time:</strong> ${totalTimeMs} ms</div>
<div><strong>Hottest Function:</strong> ${data.hottest_function || 'N/A'}</div>
<div><strong>Hottest Time:</strong> ${hottestTimeMs} ms</div>
`;
document.getElementById('record-count').textContent = `Records: ${data.total_records}`;
}
// Tab Management
function showTab(tabName) {
// Hide all tabs
const tabs = document.querySelectorAll('.tab-content');
tabs.forEach(tab => tab.classList.remove('active'));
const buttons = document.querySelectorAll('.tab-button');
buttons.forEach(btn => btn.classList.remove('active'));
// Show selected tab
document.getElementById(tabName + '-tab').classList.add('active');
event.target.classList.add('active');
}
// Flame Graph
function initializeFlameGraph() {
const width = document.getElementById('flamegraph').offsetWidth;
flamegraph = d3.flamegraph()
.width(width)
.cellHeight(18)
.transitionDuration(750)
.minFrameSize(5)
.transitionEase(d3.easeCubic)
.sort(true)
.title("")
.differential(false)
.selfValue(false);
// Color scheme
flamegraph.setColorMapper((d, originalColor) => {
// Color by depth for better visualization
const hue = (d.data.depth * 30) % 360;
return d3.hsl(hue, 0.6, 0.5);
});
d3.select("#flamegraph")
.datum({name: "root", value: 0, children: []})
.call(flamegraph);
}
function updateFlameGraph(data) {
if (!flamegraph) {
initializeFlameGraph();
}
// Update flame graph with new data
d3.select("#flamegraph")
.datum(data)
.call(flamegraph);
}
// Timeline Visualization
function updateTimeline(data) {
if (!data || data.length === 0) {
document.getElementById('timeline').innerHTML = '<p>No timeline data available</p>';
return;
}
// Convert to Plotly timeline format
const traces = [];
// Group by depth for better visualization
const maxDepth = Math.max(...data.map(d => d.depth));
for (let depth = 0; depth <= maxDepth; depth++) {
const depthData = data.filter(d => d.depth === depth);
if (depthData.length === 0) continue;
const trace = {
x: depthData.map(d => d.start),
y: depthData.map(d => depth),
text: depthData.map(d => `${d.name}<br>Duration: ${(d.duration / 1000).toFixed(3)} ms`),
mode: 'markers',
marker: {
size: depthData.map(d => Math.max(5, Math.log(d.duration + 1) * 2)),
color: depthData.map(d => d.duration),
colorscale: 'Viridis',
showscale: depth === 0,
colorbar: {
title: 'Duration (μs)'
}
},
name: `Depth ${depth}`,
hovertemplate: '%{text}<extra></extra>'
};
traces.push(trace);
}
const layout = {
title: 'Function Call Timeline',
xaxis: {
title: 'Time (μs)',
gridcolor: '#444'
},
yaxis: {
title: 'Call Depth',
gridcolor: '#444'
},
paper_bgcolor: '#252526',
plot_bgcolor: '#1e1e1e',
font: {
color: '#d4d4d4'
},
hovermode: 'closest',
showlegend: false
};
Plotly.newPlot('timeline', traces, layout, {responsive: true});
}
// Statistics Table
function updateStatistics(data) {
const tbody = document.getElementById('statistics-body');
if (!data || data.length === 0) {
tbody.innerHTML = '<tr><td colspan="7">No data available</td></tr>';
return;
}
let html = '';
data.forEach(stat => {
html += `
<tr>
<td>${stat.name}</td>
<td><code>${stat.address}</code></td>
<td>${stat.calls}</td>
<td>${stat.total_us.toLocaleString()}</td>
<td>${stat.avg_us.toFixed(2)}</td>
<td>${stat.min_us}</td>
<td>${stat.max_us}</td>
</tr>
`;
});
tbody.innerHTML = html;
}
// Handle window resize for flame graph
window.addEventListener('resize', function() {
if (flamegraph) {
const width = document.getElementById('flamegraph').offsetWidth;
flamegraph.width(width);
flamegraph.update();
}
});

View File

@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiniProfiler</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/d3-flame-graph/4.1.3/d3-flamegraph.css">
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3-flame-graph/4.1.3/d3-flamegraph.min.js"></script>
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
</head>
<body>
<div class="container">
<!-- Header -->
<header>
<h1>MiniProfiler</h1>
<p class="subtitle">Real-time Embedded Profiling Visualization</p>
</header>
<!-- Control Panel -->
<div class="control-panel">
<div class="connection-controls">
<input type="text" id="serial-port" placeholder="/dev/ttyUSB0 or COM3" value="/dev/ttyUSB0">
<input type="number" id="baudrate" placeholder="Baudrate" value="115200">
<input type="text" id="elf-path" placeholder="Path to .elf file (optional)">
<button id="connect-btn" onclick="toggleConnection()">Connect</button>
</div>
<div class="profiling-controls">
<button id="start-btn" onclick="startProfiling()" disabled>Start Profiling</button>
<button id="stop-btn" onclick="stopProfiling()" disabled>Stop Profiling</button>
<button id="clear-btn" onclick="clearData()" disabled>Clear Data</button>
<button id="reset-btn" onclick="resetBuffers()" disabled>Reset Buffers</button>
</div>
<div class="status-display">
<span class="status-indicator" id="connection-status"></span>
<span id="status-text">Disconnected</span>
<span id="record-count">Records: 0</span>
</div>
</div>
<!-- Metadata Display -->
<div id="metadata-panel" class="metadata-panel" style="display: none;">
<h3>Device Information</h3>
<div id="metadata-content"></div>
</div>
<!-- Summary Panel -->
<div id="summary-panel" class="summary-panel">
<h3>Summary</h3>
<div id="summary-content">
<p>No profiling data available</p>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab-button active" onclick="showTab('flamegraph')">Flame Graph</button>
<button class="tab-button" onclick="showTab('timeline')">Timeline</button>
<button class="tab-button" onclick="showTab('statistics')">Statistics</button>
</div>
<!-- Tab Content -->
<div id="flamegraph-tab" class="tab-content active">
<div id="flamegraph"></div>
</div>
<div id="timeline-tab" class="tab-content">
<div id="timeline"></div>
</div>
<div id="statistics-tab" class="tab-content">
<div class="table-container">
<table id="statistics-table">
<thead>
<tr>
<th>Function</th>
<th>Address</th>
<th>Calls</th>
<th>Total Time (μs)</th>
<th>Avg Time (μs)</th>
<th>Min Time (μs)</th>
<th>Max Time (μs)</th>
</tr>
</thead>
<tbody id="statistics-body">
<tr>
<td colspan="7">No data available</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
</body>
</html>