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

58
.gitignore vendored Normal file
View File

@@ -0,0 +1,58 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
ENV/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Testing
.pytest_cache/
.coverage
htmlcov/
*.bin
*.json
# OS
.DS_Store
Thumbs.db
# Embedded
*.o
*.elf
*.hex
*.bin
*.map
*.lst
*.d
*.su
# Build
embedded/build/
embedded/.build/

208
README.md Normal file
View File

@@ -0,0 +1,208 @@
# MiniProfiler
Real-time profiling tool for embedded STM32 applications using GCC's `-finstrument-functions` feature.
## Features
- **Embedded Profiling**: Automatic function instrumentation using GCC hooks
- **Real-time Visualization**: Live flame graphs, timelines, and statistics
- **Low Overhead**: DMA-based UART transmission, <5% performance impact
- **Symbol Resolution**: Automatic function name resolution from ELF/DWARF debug info
- **Web Interface**: Modern, responsive web UI with multiple visualization modes
## Architecture
MiniProfiler consists of two main components:
### 1. Embedded Module (STM32)
- Uses `__cyg_profile_func_enter/exit` hooks to capture function calls
- Lock-free ring buffer for storing profiling data
- UART/Serial communication with host
- Minimal memory footprint (~2-10KB)
### 2. Host Application (Python)
- Serial communication and protocol parsing
- ELF/DWARF symbol resolution
- Web server with real-time updates (Flask + SocketIO)
- Three visualization modes:
- **Flame Graph**: Aggregate CPU time by function
- **Timeline**: Execution over time (flame chart)
- **Statistics**: Call counts, min/max/avg durations
## Quick Start
### Installation
```bash
cd host
pip install -r requirements.txt
```
Or install as a package:
```bash
cd host
pip install -e .
```
### Running the Host Application
```bash
# Using the installed CLI
miniprofiler
# Or directly with Python
python -m miniprofiler.cli
# With custom host/port
miniprofiler --host localhost --port 8080
# With verbose logging
miniprofiler --verbose
```
### Testing Without Hardware
Generate sample profiling data to test the visualization:
```bash
cd host/tests
python sample_data_generator.py
```
This creates:
- `sample_profile_data.bin` - Binary protocol data
- `sample_flamegraph.json` - Flame graph data
- `sample_statistics.json` - Statistics data
- `sample_timeline.json` - Timeline data
### Using the Web Interface
1. Start the host application: `miniprofiler`
2. Open browser to `http://localhost:5000`
3. Enter serial port (e.g., `/dev/ttyUSB0` or `COM3`)
4. Optionally provide path to `.elf` file for symbol resolution
5. Click **Connect**
6. Click **Start Profiling**
7. View real-time profiling data in the three visualization tabs
## Protocol
### Command-Response Structure
**Commands (Host → Embedded)**
- `START_PROFILING` (0x01)
- `STOP_PROFILING` (0x02)
- `GET_STATUS` (0x03)
- `RESET_BUFFERS` (0x04)
- `GET_METADATA` (0x05)
**Responses (Embedded → Host)**
- `ACK/NACK` (0x01/0x02)
- `METADATA` (0x03)
- `STATUS` (0x04)
- `PROFILE_DATA` (0x05)
### Profile Record Format
Each profiling record is 14 bytes:
```c
struct ProfileRecord {
uint32_t func_addr; // Function address
uint32_t entry_time; // Entry timestamp (μs)
uint32_t duration_us; // Duration (μs)
uint16_t depth; // Call stack depth
} __attribute__((packed));
```
### Packet Format
```
┌─────────┬──────────┬───────────────┬─────────┬─────┐
│ Header │ Length │ Payload │ CRC │ End │
│ (0xAA55)│ (2B) │ (N bytes) │ (2B) │(0x0A)│
└─────────┴──────────┴───────────────┴─────────┴─────┘
```
## Development Roadmap
### Phase 1: Host Application ✓
- [x] Protocol implementation
- [x] Serial communication
- [x] Symbol resolution (ELF/DWARF)
- [x] Data analysis and statistics
- [x] Web interface with Flask + SocketIO
- [x] Flame graph visualization (d3-flame-graph)
- [x] Timeline visualization (Plotly.js)
- [x] Sample data generator
### Phase 2: Embedded Module (Next)
- [ ] Instrumentation hooks (`__cyg_profile_func_enter/exit`)
- [ ] DWT/SysTick timing implementation
- [ ] Ring buffer implementation
- [ ] UART communication with DMA
- [ ] Command handling
- [ ] STM32 example project
### Phase 3: Integration & Testing
- [ ] End-to-end testing with real hardware
- [ ] Performance overhead measurement
- [ ] Buffer overflow handling
- [ ] Symbol resolution verification
### Phase 4: Renode Emulation
- [ ] Renode platform description
- [ ] Virtual UART setup
- [ ] CI/CD integration
- [ ] Automated testing
## Configuration
### GCC Compilation Flags
To enable instrumentation in your embedded project:
```makefile
CFLAGS += -finstrument-functions
CFLAGS += -finstrument-functions-exclude-file-list=drivers/,lib/
```
### Excluding Functions
```c
// Exclude specific functions
void __attribute__((no_instrument_function)) driver_function(void);
// Exclude entire files
#pragma GCC optimize ("no-instrument-functions")
```
## Requirements
### Host Application
- Python 3.8+
- Flask 3.0+
- pyserial 3.5+
- pyelftools 0.29+
- Modern web browser with JavaScript enabled
### Embedded Target
- STM32 MCU (STM32F4/F7/H7 recommended)
- GCC ARM toolchain with `-finstrument-functions` support
- UART/USB-CDC peripheral
- ~2-10KB RAM for profiling buffer
## License
MIT License - See LICENSE file for details
## Contributing
Contributions welcome! Please open an issue or submit a pull request.
## Acknowledgments
- Inspired by [Brendan Gregg's FlameGraphs](https://www.brendangregg.com/flamegraphs.html)
- Uses [d3-flame-graph](https://github.com/spiermar/d3-flame-graph) for visualization
- Built with Flask, SocketIO, and Plotly.js

349
docs/GETTING_STARTED.md Normal file
View File

@@ -0,0 +1,349 @@
# Getting Started with MiniProfiler
This guide will help you get started with MiniProfiler for profiling your embedded STM32 applications.
## Prerequisites
### Host System
- Python 3.8 or higher
- pip package manager
- Modern web browser (Chrome, Firefox, Edge)
- Serial port access (USB-to-Serial adapter or built-in UART)
### Embedded Target (for Phase 2)
- STM32 microcontroller (STM32F4/F7/H7 recommended)
- GCC ARM toolchain
- UART or USB-CDC peripheral configured
- ST-Link or similar programmer/debugger
## Installation
### 1. Clone the Repository
```bash
git clone https://github.com/yourusername/miniprofiler.git
cd miniprofiler
```
### 2. Install Python Dependencies
```bash
cd host
pip install -r requirements.txt
```
Or install as a package:
```bash
pip install -e .
```
### 3. Verify Installation
```bash
miniprofiler --help
```
You should see the help message with available options.
## Testing Without Hardware
Before connecting to real hardware, you can test the visualization with sample data.
### Generate Sample Data
```bash
cd host/tests
python sample_data_generator.py
```
This creates several sample data files:
- `sample_flamegraph.json` - Flame graph visualization data
- `sample_statistics.json` - Function statistics
- `sample_timeline.json` - Timeline data
- `sample_profile_data.bin` - Binary protocol data
### View Sample Visualizations
You can view the sample JSON files by loading them in the web interface or by opening them directly:
```bash
# View flame graph data
cat sample_flamegraph.json | python -m json.tool
# View statistics
cat sample_statistics.json | python -m json.tool
```
## Running the Host Application
### Start the Web Server
```bash
# From the host directory
python run.py
# Or using the installed CLI
miniprofiler
```
The server will start on `http://localhost:5000` by default.
### Custom Host/Port
```bash
miniprofiler --host 0.0.0.0 --port 8080
```
### Enable Debug Mode
```bash
miniprofiler --debug --verbose
```
## Using the Web Interface
### 1. Open the Browser
Navigate to `http://localhost:5000`
You should see the MiniProfiler dashboard with:
- Connection controls
- Profiling controls (disabled until connected)
- Status display
- Three visualization tabs
### 2. Configure Connection
Enter your serial port details:
- **Serial Port**: `/dev/ttyUSB0` (Linux/Mac) or `COM3` (Windows)
- **Baud Rate**: `115200` (default)
- **ELF Path**: Path to your `.elf` file (optional, for symbol resolution)
**Finding Your Serial Port:**
Linux/Mac:
```bash
ls /dev/tty* | grep -i usb
# or
ls /dev/tty.usb*
```
Windows:
- Open Device Manager
- Look under "Ports (COM & LPT)"
- Note the COM port number (e.g., COM3)
### 3. Connect to Device
Click the **Connect** button.
If successful, you'll see:
- Status indicator turns green
- "Connected to /dev/ttyUSB0" message
- Metadata panel appears with device information
- Profiling controls become enabled
### 4. Start Profiling
Click **Start Profiling**.
The device will begin sending profiling data, and you'll see:
- Real-time updates in all three visualization tabs
- Record count incrementing
- Summary statistics updating
### 5. Explore Visualizations
#### Flame Graph Tab
- Shows aggregate CPU time by function
- Wider bars = more time spent
- Click to zoom into specific call stacks
- Search for functions by name
- Hover for details
#### Timeline Tab
- Shows function execution over time
- X-axis = time in microseconds
- Y-axis = call stack depth
- Color = duration (darker = longer)
- Useful for finding timing issues
#### Statistics Tab
- Sortable table of function statistics
- Columns: Function, Address, Calls, Total/Avg/Min/Max Time
- Click column headers to sort
- Find hot spots and outliers
### 6. Control Profiling
- **Stop Profiling**: Pause data collection
- **Clear Data**: Reset all visualizations
- **Reset Buffers**: Clear device-side buffers
### 7. Disconnect
Click **Disconnect** when done to close the serial connection.
## Understanding the Visualizations
### Flame Graph
The flame graph shows **aggregated** profiling data:
```
┌─────────────────────────────────┐
│ main (10s) │ ← Root function
├──────────────┬──────────────────┤
│ app_loop │ process_data │ ← Called by main
│ (6s) │ (4s) │
├──────┬───────┼──────────────────┤
│ read │ write │ calculate │ ← Nested calls
│ (3s) │ (3s) │ (4s) │
└──────┴───────┴──────────────────┘
```
**Interpretation:**
- Width = total time (including children)
- Read from bottom (root) to top (leaves)
- Widest bars are hotspots to optimize
### Timeline
The timeline shows **chronological** execution:
```
Time ───────────────────────►
│ ████ func_a
│ ██ func_b (called by func_a)
│ ████ func_c
│ ██ func_d
```
**Interpretation:**
- X-axis = time progression
- Y-axis = call depth
- Gaps = idle time or excluded functions
- Useful for timing analysis and debugging
### Statistics Table
| Function | Calls | Total Time | Avg Time |
|----------|-------|------------|----------|
| main | 1 | 10000 μs | 10000 μs |
| app_loop | 100 | 6000 μs | 60 μs |
| calculate | 100 | 4000 μs | 40 μs |
**Interpretation:**
- Calls = number of times function was called
- Total = cumulative time across all calls
- Avg = total / calls
- Min/Max = shortest/longest single execution
## Troubleshooting
### "Failed to connect to /dev/ttyUSB0"
**Possible causes:**
- Wrong port name
- Port in use by another application
- Insufficient permissions
**Solutions:**
```bash
# Linux: Check permissions
ls -l /dev/ttyUSB0
sudo chmod 666 /dev/ttyUSB0
# Or add user to dialout group
sudo usermod -a -G dialout $USER
# Log out and back in
# Check if port is in use
lsof | grep ttyUSB0
```
### No Data Appearing
**Check:**
1. Is profiling started? (Click "Start Profiling")
2. Is embedded device actually profiling?
3. Is UART configured correctly on embedded side?
4. Check baud rate matches on both sides
5. Look for errors in browser console (F12)
### CRC Errors in Console
**Possible causes:**
- Baud rate mismatch
- Electrical noise on UART lines
- Cable issues
**Solutions:**
- Verify baud rate configuration
- Use shielded cable
- Add delays in embedded UART transmission
- Reduce baud rate to 57600
### Buffer Overflows
**Symptoms:**
- `buffer_overflows` counter > 0 in device status
- Missing profiling data
**Solutions:**
- Increase baud rate (460800 or 921600)
- Increase embedded ring buffer size
- Reduce instrumentation (exclude more files)
- Use sampling mode (future feature)
### Symbols Not Resolved
**Symptoms:**
- Function names show as `func_0x08000XXX` or `unknown_0x08000XXX`
**Solutions:**
- Provide path to `.elf` file in connection settings
- Ensure `.elf` file has debug symbols (`-g` flag)
- Verify `.elf` file matches firmware on device
- Check build ID in metadata matches
### Web Interface Not Loading
**Check:**
1. Is server running? Look for "Starting web server..." message
2. Correct URL? Should be `http://localhost:5000`
3. Port already in use? Try different port: `miniprofiler --port 8080`
4. Firewall blocking? Add exception for Python/Flask
## Next Steps
### For Development
1. Read [PROTOCOL.md](PROTOCOL.md) to understand the communication protocol
2. Review the code in `host/miniprofiler/` to customize behavior
3. Modify visualizations in `host/web/`
### For Embedded Integration
1. Wait for Phase 2 implementation of embedded module
2. Or start implementing based on protocol specification
3. See examples in `embedded/` directory (coming soon)
### For Testing
1. Create custom sample data with `sample_data_generator.py`
2. Test with Renode emulation (Phase 4)
3. Benchmark overhead on real hardware
## Support
- **Documentation**: See `docs/` directory
- **Issues**: Open an issue on GitHub
- **Examples**: Check `examples/` directory (coming soon)
## What's Next?
After getting familiar with the host application:
1. **Phase 2**: Implement embedded module for STM32
2. **Phase 3**: Test on real hardware
3. **Phase 4**: Set up Renode emulation for automated testing
Stay tuned for updates!

422
docs/PROJECT_STRUCTURE.md Normal file
View File

@@ -0,0 +1,422 @@
# MiniProfiler Project Structure
## Directory Layout
```
MiniProfiler/
├── docs/ # Documentation
│ ├── GETTING_STARTED.md # Quick start guide
│ ├── PROTOCOL.md # Communication protocol specification
│ └── PROJECT_STRUCTURE.md # This file
├── host/ # Host application (Python)
│ ├── miniprofiler/ # Main package
│ │ ├── __init__.py # Package initialization
│ │ ├── analyzer.py # Data analysis and visualization data generation
│ │ ├── cli.py # Command-line interface
│ │ ├── protocol.py # Binary protocol implementation
│ │ ├── serial_reader.py # Serial communication
│ │ ├── symbolizer.py # ELF/DWARF symbol resolution
│ │ └── web_server.py # Flask web server with SocketIO
│ │
│ ├── web/ # Web interface assets
│ │ ├── static/
│ │ │ ├── css/
│ │ │ │ └── style.css # Stylesheet
│ │ │ └── js/
│ │ │ └── app.js # JavaScript application logic
│ │ └── templates/
│ │ └── index.html # Main HTML template
│ │
│ ├── tests/ # Tests and utilities
│ │ ├── __init__.py
│ │ └── sample_data_generator.py # Generate mock profiling data
│ │
│ ├── requirements.txt # Python dependencies
│ ├── setup.py # Package setup
│ └── run.py # Quick start script
├── embedded/ # Embedded module (Phase 2 - TODO)
│ ├── src/
│ ├── inc/
│ └── examples/
├── .gitignore # Git ignore rules
├── CLAUDE.md # Project overview for Claude
└── README.md # Main project README
```
## Module Descriptions
### Host Application (`host/miniprofiler/`)
#### `protocol.py`
**Purpose:** Binary protocol implementation for serial communication
**Key Components:**
- `ProfileRecord`: Data class for profiling records (14 bytes)
- `Metadata`: Device metadata (MCU clock, timer freq, etc.)
- `StatusInfo`: Device status information
- `CommandPacket`: Commands sent to device
- `ResponsePacket`: Responses from device
- CRC16 calculation and validation
**Used by:** `serial_reader.py`, `analyzer.py`, `sample_data_generator.py`
---
#### `serial_reader.py`
**Purpose:** Serial port communication and packet parsing
**Key Components:**
- `SerialReader`: Main class for serial I/O
- Background thread for continuous reading
- State machine for packet parsing
- Callback-based event handling
- Command sending (START, STOP, GET_STATUS, etc.)
**Callbacks:**
- `on_profile_data`: Profiling records received
- `on_metadata`: Device metadata received
- `on_status`: Status update received
- `on_error`: Error occurred
**Used by:** `web_server.py`
---
#### `symbolizer.py`
**Purpose:** Resolve function addresses to names using ELF/DWARF debug info
**Key Components:**
- `Symbolizer`: ELF file parser
- Loads symbol table from `.elf` file
- Parses DWARF debug info for file/line mappings
- Address-to-name resolution
- Handles function address ranges
**Dependencies:** `pyelftools`
**Used by:** `analyzer.py`, `web_server.py`
---
#### `analyzer.py`
**Purpose:** Analyze profiling data and generate visualization data structures
**Key Components:**
- `ProfileAnalyzer`: Main analysis engine
- Build call tree from flat records
- Compute statistics (call counts, durations)
- Generate flame graph data (d3-flame-graph format)
- Generate timeline data (Plotly format)
- Generate statistics table data
**Data Structures:**
- `CallTreeNode`: Hierarchical call tree
- `FunctionStats`: Per-function statistics
**Used by:** `web_server.py`
---
#### `web_server.py`
**Purpose:** Flask web server with SocketIO for real-time updates
**Key Components:**
- `ProfilerWebServer`: Main server class
- Flask HTTP routes (`/`, `/api/status`, `/api/flamegraph`, etc.)
- SocketIO event handlers (connect, start_profiling, etc.)
- Integrates `SerialReader`, `Symbolizer`, and `ProfileAnalyzer`
- Real-time data streaming to web clients
**Routes:**
- `GET /`: Main web interface
- `GET /api/status`: Server status JSON
- `GET /api/flamegraph`: Flame graph data JSON
- `GET /api/timeline`: Timeline data JSON
- `GET /api/statistics`: Statistics table JSON
**SocketIO Events:**
- `connect_serial`: Connect to device
- `start_profiling`: Start profiling
- `stop_profiling`: Stop profiling
- `clear_data`: Clear all data
- Emits: `flamegraph_update`, `statistics_update`, etc.
**Used by:** `cli.py`
---
#### `cli.py`
**Purpose:** Command-line interface entry point
**Key Components:**
- Argument parsing (--host, --port, --debug, --verbose)
- Logging configuration
- Server initialization and startup
**Entry point:** `miniprofiler` command
---
### Web Interface (`host/web/`)
#### `templates/index.html`
**Purpose:** Main HTML page structure
**Features:**
- Connection controls (serial port, baud rate, ELF path)
- Profiling controls (start, stop, clear, reset)
- Status display
- Metadata panel
- Summary panel
- Three-tab interface (Flame Graph, Timeline, Statistics)
**Dependencies:**
- Socket.IO client
- D3.js
- d3-flame-graph
- Plotly.js
---
#### `static/css/style.css`
**Purpose:** Styling and layout
**Features:**
- Dark theme (VSCode-inspired)
- Responsive design
- Flexbox layouts
- Custom button styles
- Table styling
- Status indicators with animations
---
#### `static/js/app.js`
**Purpose:** Client-side application logic
**Key Functions:**
- `initializeSocket()`: Set up SocketIO connection
- `toggleConnection()`: Connect/disconnect from device
- `startProfiling()`, `stopProfiling()`: Control profiling
- `updateFlameGraph()`: Render flame graph with d3-flame-graph
- `updateTimeline()`: Render timeline with Plotly.js
- `updateStatistics()`: Update statistics table
- `showTab()`: Tab switching
**Event Handlers:**
- Socket events (connect, disconnect, data updates)
- Button clicks
- Window resize
---
### Tests (`host/tests/`)
#### `sample_data_generator.py`
**Purpose:** Generate realistic mock profiling data for testing
**Features:**
- Simulates typical embedded application (main, init, loop, sensors, etc.)
- Generates nested function calls with realistic timing
- Creates binary protocol packets
- Exports JSON files for visualization testing
**Outputs:**
- `sample_profile_data.bin`: Binary protocol data
- `sample_flamegraph.json`: Flame graph data
- `sample_statistics.json`: Statistics data
- `sample_timeline.json`: Timeline data
**Usage:**
```bash
cd host/tests
python sample_data_generator.py
```
---
## Data Flow
### Connection and Initialization
```
User Web UI Web Server Serial Reader Device
│ │ │ │ │
│─── Open Browser ──►│ │ │ │
│ │ │ │ │
│─── Enter Port ────►│ │ │ │
│─── Click Connect ─►│─── connect_serial ──►│─── connect() ─────►│ │
│ │ │ │─── Open ─────►│
│ │ │ │ │
│ │ │─── get_metadata() ►│─── CMD ──────►│
│ │ │ │◄── METADATA ──│
│ │◄── metadata ─────────│◄── on_metadata() ──│ │
│◄── Display Info ───│ │ │ │
```
### Profiling Session
```
User Web UI Web Server Analyzer Device
│ │ │ │ │
│─── Start ─────────►│─── start_profiling ─►│─── start() ─────►│ │
│ │ │ │─── CMD ────────►│
│ │ │ │ │
│ │ │ │◄── DATA ────────│
│ │ │◄── on_profile ───│ │
│ │ │ │ │
│ │ │── add_records() ►│ │
│ │ │ │─ Analyze │
│ │ │ │─ Build Tree │
│ │ │ │─ Compute Stats │
│ │ │◄── JSON ─────────│ │
│ │◄─ flamegraph_update ─│ │ │
│◄── Update Viz ─────│ │ │ │
```
## Technology Stack
### Backend
- **Python 3.8+**: Main language
- **Flask 3.0+**: Web framework
- **Flask-SocketIO 5.3+**: Real-time WebSocket communication
- **pyserial 3.5+**: Serial port communication
- **pyelftools 0.29+**: ELF/DWARF parsing
- **crc 6.1+**: CRC16 calculation
- **eventlet**: Async I/O for SocketIO
### Frontend
- **HTML5/CSS3**: Structure and styling
- **JavaScript (ES6)**: Application logic
- **Socket.IO Client**: Real-time communication
- **D3.js v7**: Visualization library
- **d3-flame-graph 4.1**: Flame graph component
- **Plotly.js 2.27**: Timeline/chart visualization
### Development Tools
- **setuptools**: Package management
- **pip**: Dependency management
- **git**: Version control
## Configuration Files
### `requirements.txt`
Python package dependencies with minimum versions
### `setup.py`
Package metadata and installation configuration
- Entry point: `miniprofiler` CLI command
- Package data includes web assets
### `.gitignore`
Excludes:
- Python bytecode and caches
- Virtual environments
- IDE configs
- Build artifacts
- Generated test data
## Key Design Decisions
### Why Command-Response Protocol?
- Allows host to control profiling (start/stop)
- Can request status and metadata
- More flexible than auto-start mode
- Small overhead acceptable at 115200 baud
### Why Entry Time + Duration?
- Enables both flame graphs (aggregate) and timelines (chronological)
- Only 40% more data than duration-only
- Essential for debugging timing-sensitive embedded systems
### Why d3-flame-graph?
- Industry standard for flame graph visualization
- Interactive (zoom, search, tooltips)
- Customizable colors and layout
- Handles large datasets efficiently
### Why Separate Analyzer Module?
- Decouples data processing from I/O
- Easier to test in isolation
- Can swap visualization formats without changing protocol
- Allows offline analysis of captured data
## Extension Points
### Adding New Commands
1. Add to `Command` enum in `protocol.py`
2. Implement in `SerialReader.send_command()`
3. Add handler in `web_server.py` SocketIO events
4. Update embedded firmware to handle command
### Adding New Visualizations
1. Add route in `web_server.py` (e.g., `/api/callgraph`)
2. Implement data generation in `analyzer.py`
3. Add HTML tab in `index.html`
4. Add JavaScript rendering in `app.js`
5. Update CSS as needed
### Supporting More Microcontrollers
1. Ensure GCC toolchain supports `-finstrument-functions`
2. Implement timing mechanism (DWT, SysTick, or custom timer)
3. Port ring buffer and UART code to new MCU
4. Test and document
### Adding Compression
1. Update protocol version to 0x02
2. Implement compression in embedded module (e.g., delta encoding)
3. Add decompression in `protocol.py`
4. Update `ProfileDataPayload` parsing
## Future Enhancements
### Phase 2: Embedded Module
- [ ] STM32 HAL/LL implementation
- [ ] FreeRTOS integration
- [ ] Example projects for STM32F4/F7/H7
- [ ] CMake build system
### Phase 3: Advanced Features
- [ ] Statistical sampling mode
- [ ] ISR profiling
- [ ] Multi-core support (dual-core STM32H7)
- [ ] Task/thread tracking for RTOS
- [ ] Filtering and search
### Phase 4: Renode Integration
- [ ] Renode platform description
- [ ] Virtual UART setup
- [ ] CI/CD integration
- [ ] Automated regression tests
### Phase 5: Analysis Tools
- [ ] Differential profiling (compare two runs)
- [ ] Export to Chrome Trace Format
- [ ] Call graph visualization
- [ ] Performance regression detection
- [ ] Integration with debuggers (GDB)
## Performance Targets
### Embedded Overhead
- **Target**: <5% CPU overhead
- **Memory**: 2-10 KB RAM for buffers
- **Instrumentation**: 1-2 μs per function call
### Host Performance
- **Latency**: <100ms from device to visualization
- **Throughput**: Handle 500-1000 records/sec
- **Memory**: Scale to 100K+ records in browser
### Bandwidth
- **115200 baud**: ~780 records/sec
- **460800 baud**: ~3100 records/sec
- **921600 baud**: ~6200 records/sec
## Contributing
See individual module docstrings for implementation details.
Follow existing code style and structure when adding features.

300
docs/PROTOCOL.md Normal file
View File

@@ -0,0 +1,300 @@
# MiniProfiler Communication Protocol
## Overview
MiniProfiler uses a binary command-response protocol over UART/Serial communication at 115200 baud (configurable).
- **Command packets**: Host → Embedded device
- **Response packets**: Embedded device → Host
## Command Packet Format
Commands are sent from the host to the embedded device.
### Structure
```
┌────────┬─────────┬─────────────┬──────────┬──────────┐
│ Header │ Command │ Payload Len │ Payload │ Checksum │
│ (1B) │ (1B) │ (1B) │ (8B) │ (1B) │
└────────┴─────────┴─────────────┴──────────┴──────────┘
Total: 12 bytes
```
### Fields
| Field | Size | Value | Description |
|-------|------|-------|-------------|
| Header | 1 byte | `0x55` | Packet start marker |
| Command | 1 byte | See table below | Command code |
| Payload Length | 1 byte | 0-8 | Actual payload size |
| Payload | 8 bytes | Variable | Command parameters (padded with 0x00) |
| Checksum | 1 byte | Sum of all bytes & 0xFF | Simple checksum |
### Command Codes
| Command | Code | Description | Payload |
|---------|------|-------------|---------|
| START_PROFILING | `0x01` | Start profiling | None |
| STOP_PROFILING | `0x02` | Stop profiling | None |
| GET_STATUS | `0x03` | Request status | None |
| RESET_BUFFERS | `0x04` | Clear profiling buffers | None |
| GET_METADATA | `0x05` | Request device metadata | None |
| SET_CONFIG | `0x06` | Configure profiler | Config bytes (reserved) |
### Example
Start profiling command:
```
55 01 00 00 00 00 00 00 00 00 00 56
│ │ │ └─────────────────────┘ │
│ │ │ Payload (8B) │
│ │ └── Payload Length (0) │
│ └── Command (START_PROFILING) │
└── Header (0x55) └── Checksum
```
## Response Packet Format
Responses are sent from the embedded device to the host.
### Structure
```
┌─────────┬──────┬──────────┬──────────┬────────┬─────┐
│ Header │ Type │ Length │ Payload │ CRC │ End │
│ (2B) │ (1B) │ (2B) │ (N bytes)│ (2B) │(1B) │
└─────────┴──────┴──────────┴──────────┴────────┴─────┘
Total: 8 + N bytes
```
### Fields
| Field | Size | Value | Description |
|-------|------|-------|-------------|
| Header | 2 bytes | `0xAA55` | Packet start marker (little-endian) |
| Type | 1 byte | See table below | Response type |
| Length | 2 bytes | 0-65535 | Payload size (little-endian) |
| Payload | Variable | Depends on type | Response data |
| CRC16 | 2 bytes | CRC16-CCITT | Checksum of header+type+length+payload |
| End | 1 byte | `0x0A` | Packet end marker (newline) |
### Response Types
| Type | Code | Description | Payload Format |
|------|------|-------------|----------------|
| ACK | `0x01` | Command acknowledged | None |
| NACK | `0x02` | Command failed | None |
| METADATA | `0x03` | Device metadata | See Metadata Payload |
| STATUS | `0x04` | Device status | See Status Payload |
| PROFILE_DATA | `0x05` | Profiling records | See Profile Data Payload |
## Payload Formats
### Metadata Payload (28 bytes)
Sent in response to `GET_METADATA` command or automatically on startup.
```c
struct MetadataPayload {
uint32_t mcu_clock_hz; // MCU clock frequency in Hz
uint32_t timer_freq; // Profiling timer frequency in Hz
uint32_t elf_build_id; // CRC32 of .text section for version matching
char fw_version[16]; // Firmware version string (null-terminated)
} __attribute__((packed));
```
**Example:**
- MCU Clock: 168,000,000 Hz (168 MHz STM32F4)
- Timer Freq: 1,000,000 Hz (1 MHz for microsecond precision)
- Build ID: 0xDEADBEEF
- FW Version: "v1.0.0"
### Status Payload (10 bytes)
Sent in response to `GET_STATUS` command.
```c
struct StatusPayload {
uint8_t is_profiling; // 1 if profiling active, 0 otherwise
uint32_t buffer_overflows; // Number of buffer overflow events
uint32_t records_captured; // Total records captured
uint8_t buffer_usage_percent; // Current buffer usage (0-100)
} __attribute__((packed));
```
### Profile Data Payload (Variable)
Sent automatically during profiling or in response to data requests.
```c
struct ProfileDataPayload {
uint8_t version; // Protocol version (0x01)
uint16_t record_count; // Number of records in this packet
ProfileRecord records[]; // Array of profile records
} __attribute__((packed));
```
Each `ProfileRecord` is 14 bytes:
```c
struct ProfileRecord {
uint32_t func_addr; // Function address (from instrumentation)
uint32_t entry_time; // Entry timestamp in microseconds
uint32_t duration_us; // Function duration in microseconds
uint16_t depth; // Call stack depth (0 = root)
} __attribute__((packed));
```
**Field Details:**
- `func_addr`: Return address from `__builtin_return_address(0)` in instrumentation hook
- `entry_time`: Microsecond timestamp when function was entered (wraps at ~71 minutes)
- `duration_us`: Time spent in function including children
- `depth`: Call stack depth (0 for main, 1 for functions called by main, etc.)
## Communication Flow
### Initial Connection
```
Host Device
| |
|--- GET_METADATA ------>|
|<---- METADATA ---------|
| |
|--- START_PROFILING --->|
|<---- ACK --------------|
| |
|<---- PROFILE_DATA -----| (continuous stream)
|<---- PROFILE_DATA -----|
|<---- PROFILE_DATA -----|
| ... |
```
### Typical Session
```
1. Host connects to serial port
2. Host sends GET_METADATA
3. Device responds with METADATA packet
4. Host sends START_PROFILING
5. Device responds with ACK
6. Device begins streaming PROFILE_DATA packets
7. Host processes and visualizes data in real-time
8. Host sends STOP_PROFILING when done
9. Device responds with ACK and stops streaming
```
## Error Handling
### CRC Mismatch
If the host detects a CRC mismatch:
- Log the error
- Discard the packet
- Continue listening for next packet
- No retransmission (real-time streaming)
### Packet Loss
- Sequence numbers not implemented (keeps protocol simple)
- Missing data will create gaps in visualization
- Not critical for profiling use case
### Buffer Overflow
- Device sets `buffer_overflows` counter in status
- Host should warn user
- Options: increase baud rate, reduce instrumentation, or use sampling
## Performance Considerations
### Bandwidth Calculation
At 115200 baud:
- Effective throughput: ~11.5 KB/s
- Profile record size: 14 bytes
- Packet overhead: ~8 bytes per packet
- Records per packet (typical): 20
- Packet size: 8 + 3 + 280 = 291 bytes
- Packets per second: ~39
- Records per second: ~780
**Recommendation:** If profiling >780 function calls/sec, increase baud rate to 460800 or 921600.
### Timing Overhead
Instrumentation overhead per function:
- Entry hook: ~0.5-1 μs
- Exit hook: ~0.5-1 μs
- Total: ~1-2 μs per function call
Target: <5% overhead for typical applications.
## Protocol Versioning
Current version: **0x01**
The `version` field in `ProfileDataPayload` allows for future protocol extensions:
- v0x01: Current format (entry_time + duration)
- v0x02: Future - could add ISR markers, task IDs, etc.
- v0x03: Future - compressed format, delta encoding
Host should check version and handle accordingly or reject unsupported versions.
## Example Packet Dumps
### GET_METADATA Command
```
55 05 00 00 00 00 00 00 00 00 00 5A
```
### METADATA Response
```
AA 55 03 1C 00 // Header, Type=METADATA, Length=28
00 09 FB 0A // mcu_clock_hz = 168000000
40 42 0F 00 // timer_freq = 1000000
EF BE AD DE // build_id = 0xDEADBEEF
76 31 2E 30 2E 30 00 ... // fw_version = "v1.0.0\0..."
XX XX // CRC16
0A // End marker
```
### PROFILE_DATA Response (2 records)
```
AA 55 05 1F 00 // Header, Type=PROFILE_DATA, Length=31
01 // Version = 1
02 00 // Record count = 2
// Record 1
00 01 00 08 // func_addr = 0x08000100
E8 03 00 00 // entry_time = 1000 μs
D0 07 00 00 // duration = 2000 μs
00 00 // depth = 0
// Record 2
20 02 00 08 // func_addr = 0x08000220
F4 01 00 00 // entry_time = 500 μs
2C 01 00 00 // duration = 300 μs
01 00 // depth = 1
XX XX // CRC16
0A // End marker
```
## Implementation Notes
### Embedded Side
- Use DMA for UART transmission to minimize CPU overhead
- Implement ring buffer with power-of-2 size for efficient modulo operations
- Send packets in background task or idle hook
- Consider double-buffering: one buffer for capturing, one for transmitting
### Host Side
- Use state machine for packet parsing (don't assume atomicity)
- Handle partial packets gracefully
- Verify CRC before processing payload
- Use background thread for serial reading to not block UI
## References
- CRC16-CCITT: Polynomial 0x1021, initial value 0xFFFF
- Little-endian byte order for multi-byte integers
- GCC instrumentation: `__cyg_profile_func_enter/exit`

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>