Initialized MiniProfiler project
- Contains the host code with a protocol implementation, data analyser and web-based visualiser
This commit is contained in:
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal 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
208
README.md
Normal 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
349
docs/GETTING_STARTED.md
Normal 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
422
docs/PROJECT_STRUCTURE.md
Normal 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
300
docs/PROTOCOL.md
Normal 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`
|
||||||
6
host/miniprofiler/__init__.py
Normal file
6
host/miniprofiler/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""MiniProfiler Host Application
|
||||||
|
|
||||||
|
A Python-based host application for profiling embedded STM32 applications.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
314
host/miniprofiler/analyzer.py
Normal file
314
host/miniprofiler/analyzer.py
Normal 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
90
host/miniprofiler/cli.py
Normal 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()
|
||||||
280
host/miniprofiler/protocol.py
Normal file
280
host/miniprofiler/protocol.py
Normal 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
|
||||||
267
host/miniprofiler/serial_reader.py
Normal file
267
host/miniprofiler/serial_reader.py
Normal 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()
|
||||||
205
host/miniprofiler/symbolizer.py
Normal file
205
host/miniprofiler/symbolizer.py
Normal 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()
|
||||||
315
host/miniprofiler/web_server.py
Normal file
315
host/miniprofiler/web_server.py
Normal 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
7
host/requirements.txt
Normal 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
13
host/run.py
Executable 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
51
host/setup.py
Normal 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
1
host/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Test utilities for MiniProfiler."""
|
||||||
236
host/tests/sample_data_generator.py
Normal file
236
host/tests/sample_data_generator.py
Normal 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")
|
||||||
282
host/web/static/css/style.css
Normal file
282
host/web/static/css/style.css
Normal 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
340
host/web/static/js/app.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
101
host/web/templates/index.html
Normal file
101
host/web/templates/index.html
Normal 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>
|
||||||
Reference in New Issue
Block a user