Skip to content

Commit 6619c5d

Browse files
committed
Add uasyncio monitor v3/as_demos/monitor.
1 parent 3c8817d commit 6619c5d

File tree

6 files changed

+447
-0
lines changed

6 files changed

+447
-0
lines changed

v3/as_demos/monitor/README.md

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# 1. A uasyncio monitor
2+
3+
This library provides a means of examining the behaviour of a running
4+
`uasyncio` system. The device under test is linked to a Raspberry Pi Pico. The
5+
latter displays the behaviour of the host by pin changes and/or optional print
6+
statements. Communication with the Pico is uni-directional via a UART so only a
7+
single GPIO pin is used - at last a use for the ESP8266 transmit-only UART(1).
8+
9+
A logic analyser or scope provides an insight into the way an asynchronous
10+
application is working.
11+
12+
Where an application runs multiple concurrent tasks it can be difficult to
13+
locate a task which is hogging CPU time. Long blocking periods can also result
14+
from several tasks each of which can block for a period. If, on occasion, these
15+
are scheduled in succession, the times can add. The monitor issues a trigger
16+
when the blocking period exceeds a threshold. With a logic analyser the system
17+
state at the time of the transient event may be examined.
18+
19+
The following image shows the `quick_test.py` code being monitored at the point
20+
when a task hogs the CPU. The top line 00 shows the "hog detect" trigger. Line
21+
02 shows the fast running `hog_detect` task which cannot run at the time of the
22+
trigger. Lines 01 and 03 show the `foo` and `bar` tasks.
23+
![Image](/.monitor.jpg)
24+
25+
## 1.1 Pre-requisites
26+
27+
The device being monitored must run firmware V1.17 or later. The `uasyncio`
28+
version should be V3 (as included in the firmware).
29+
30+
## 1.2 Usage
31+
32+
Example script `quick_test.py` provides a usage example.
33+
34+
An application to be monitored typically has the following setup code:
35+
```python
36+
from monitor import monitor, hog_detect, set_uart
37+
set_uart(2) # Define device under test UART no.
38+
```
39+
40+
Coroutines to be monitored are prefixed with the `@monitor` decorator:
41+
```python
42+
@monitor(2, 3)
43+
async def my_coro():
44+
# code
45+
```
46+
The decorator args are as follows:
47+
1. A unique `ident` for the code being monitored. Determines the pin number on
48+
the Pico. See [Pico Pin mapping](./README.md#3-pico-pin-mapping).
49+
2. An optional arg defining the maximum number of concurrent instances of the
50+
task to be independently monitored (default 1).
51+
52+
Whenever the code runs, a pin on the Pico will go high, and when the code
53+
terminates it will go low. This enables the behaviour of the system to be
54+
viewed on a logic analyser or via console output on the Pico. This behavior
55+
works whether the code terminates normally, is cancelled or has a timeout.
56+
57+
In the example above, when `my_coro` starts, the pin defined by `ident==2`
58+
(GPIO 4) will go high. When it ends, the pin will go low. If, while it is
59+
running, a second instance of `my_coro` is launched, the next pin (GPIO 5) will
60+
go high. Pins will go low when the relevant instance terminates, is cancelled,
61+
or times out. If more instances are started than were specified to the
62+
decorator, a warning will be printed on the host. All excess instances will be
63+
associated with the final pin (`pins[ident + max_instances - 1]`) which will
64+
only go low when all instances associated with that pin have terminated.
65+
66+
Consequently if `max_instances=1` and multiple instances are launched, a
67+
warning will appear on the host; the pin will go high when the first instance
68+
starts and will not go low until all have ended.
69+
70+
## 1.3 Detecting CPU hogging
71+
72+
A common cause of problems in asynchronous code is the case where a task blocks
73+
for a period, hogging the CPU, stalling the scheduler and preventing other
74+
tasks from running. Determining the task responsible can be difficult.
75+
76+
The pin state only indicates that the task is running. A pin state of 1 does
77+
not imply CPU hogging. Thus
78+
```python
79+
@monitor(3)
80+
async def long_time():
81+
await asyncio.sleep(30)
82+
```
83+
will cause the pin to go high for 30s, even though the task is consuming no
84+
resources for that period.
85+
86+
To provide a clue about CPU hogging, a `hog_detect` coroutine is provided. This
87+
has `ident=0` and, if used, is monitored on GPIO 2. It loops, yielding to the
88+
scheduler. It will therefore be scheduled in round-robin fashion at speed. If
89+
long gaps appear in the pulses on GPIO 2, other tasks are hogging the CPU.
90+
Usage of this is optional. To use, issue
91+
```python
92+
import uasyncio as asyncio
93+
from monitor import monitor, hog_detect
94+
# code omitted
95+
asyncio.create_task(hog_detect())
96+
# code omitted
97+
```
98+
To aid in detecting the gaps in execution, the Pico code implements a timer.
99+
This is retriggered by activity on `ident=0`. If it times out, a brief high
100+
going pulse is produced on pin 28, along with the console message "Hog". The
101+
pulse can be used to trigger a scope or logic analyser. The duration of the
102+
timer may be adjusted - see [section 4](./README.md~4-the-pico-code).
103+
104+
# 2. Monitoring synchronous code
105+
106+
In general there are easier ways to debug synchronous code. However in the
107+
context of a monitored asynchronous application there may be a need to view the
108+
timing of synchronous code. Functions and methods may be monitored either in
109+
the declaration via a decorator or when called via a context manager.
110+
111+
## 2.1 The mon_func decorator
112+
113+
This works as per the asynchronous decorator, but without the `max_instances`
114+
arg. This will activate the GPIO associated with ident 20 for the duration of
115+
every call to `sync_func()`:
116+
```python
117+
@mon_func(20)
118+
def sync_func():
119+
pass
120+
```
121+
122+
## 2.2 The mon_call context manager
123+
124+
This may be used to monitor a function only when called from specific points in
125+
the code.
126+
```python
127+
def another_sync_func():
128+
pass
129+
130+
with mon_call(22):
131+
another_sync_func()
132+
```
133+
134+
It is advisable not to use the context manager with a function having the
135+
`mon_func` decorator. The pin and report behaviour is confusing.
136+
137+
# 3. Pico Pin mapping
138+
139+
The Pico GPIO numbers start at 2 to allow for UART(0) and also have a gap where
140+
GPIO's are used for particular purposes. This is the mapping between `ident`
141+
GPIO no. and Pico PCB pin, with the pins for the timer and the UART link also
142+
identified:
143+
144+
| ident | GPIO | pin |
145+
|:-----:|:----:|:----:|
146+
| uart | 1 | 2 |
147+
| 0 | 2 | 4 |
148+
| 1 | 3 | 5 |
149+
| 2 | 4 | 6 |
150+
| 3 | 5 | 7 |
151+
| 4 | 6 | 9 |
152+
| 5 | 7 | 10 |
153+
| 6 | 8 | 11 |
154+
| 7 | 9 | 12 |
155+
| 8 | 10 | 14 |
156+
| 9 | 11 | 15 |
157+
| 10 | 12 | 16 |
158+
| 11 | 13 | 17 |
159+
| 12 | 14 | 19 |
160+
| 13 | 15 | 20 |
161+
| 14 | 16 | 21 |
162+
| 15 | 17 | 22 |
163+
| 16 | 18 | 24 |
164+
| 17 | 19 | 25 |
165+
| 18 | 20 | 26 |
166+
| 19 | 21 | 27 |
167+
| 20 | 22 | 29 |
168+
| 21 | 26 | 31 |
169+
| 22 | 27 | 32 |
170+
| timer | 28 | 34 |
171+
172+
The host's UART `txd` pin should be connected to Pico GPIO 1 (pin 2). There
173+
must be a link between `Gnd` pins on the host and Pico.
174+
175+
# 4. The Pico code
176+
177+
Monitoring of the UART with default behaviour is started as follows:
178+
```python
179+
from monitor_pico import run
180+
run()
181+
```
182+
By default the Pico does not produce console output and the timer has a period
183+
of 100ms - pin 28 will pulse if ident 0 is inactive for over 100ms. These
184+
behaviours can be modified by the following `run` args:
185+
1. `period=100` Define the timer period in ms.
186+
2. `verbose=()` Determines which `ident` values should produce console output.
187+
188+
Thus to run such that idents 4 and 7 produce console output, with hogging
189+
reported if blocking is for more than 60ms, issue
190+
```python
191+
from monitor_pico import run
192+
run(60, (4, 7))
193+
```
194+
195+
# 5. Design notes
196+
197+
The use of decorators is intended to ease debugging: they are readily turned on
198+
and off by commenting out.
199+
200+
The Pico was chosen for extremely low cost. It has plenty of GPIO pins and no
201+
underlying OS to introduce timing uncertainties.
202+
203+
Symbols transmitted by the UART are printable ASCII characters to ease
204+
debugging. A single byte protocol simplifies and speeds the Pico code.
205+
206+
The baudrate of 1Mbps was chosen to minimise latency (10μs per character is
207+
fast in the context of uasyncio). It also ensures that tasks like `hog_detect`,
208+
which can be scheduled at a high rate, can't overflow the UART buffer. The
209+
1Mbps rate seems widely supported.

v3/as_demos/monitor/monitor.jpg

49.5 KB
Loading

v3/as_demos/monitor/monitor.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# monitor.py
2+
# Monitor an asynchronous program by sending single bytes down an interface.
3+
4+
# Copyright (c) 2021 Peter Hinch
5+
# Released under the MIT License (MIT) - see LICENSE file
6+
7+
import uasyncio as asyncio
8+
from machine import UART
9+
10+
uart = None
11+
def set_uart(n): # Monitored app defines interface
12+
global uart
13+
uart = UART(n, 1_000_000)
14+
15+
_available = set(range(0, 23)) # Valid idents are 0..22
16+
17+
def _validate(ident, num=1):
18+
if ident >= 0 and ident + num <= 23:
19+
try:
20+
for x in range(ident, ident + num):
21+
_available.remove(x)
22+
except KeyError:
23+
raise ValueError(f'Monitor error - ident {x:02} already allocated.')
24+
else:
25+
raise ValueError(f'Monitor error - ident {ident:02} out of range.')
26+
27+
28+
def monitor(n, max_instances=1):
29+
def decorator(coro):
30+
# This code runs before asyncio.run()
31+
_validate(n, max_instances)
32+
instance = 0
33+
async def wrapped_coro(*args, **kwargs):
34+
# realtime
35+
nonlocal instance
36+
d = 0x40 + n + min(instance, max_instances - 1)
37+
v = bytes(chr(d), 'utf8')
38+
instance += 1
39+
if instance > max_instances:
40+
print(f'Monitor {n:02} max_instances reached')
41+
uart.write(v)
42+
try:
43+
res = await coro(*args, **kwargs)
44+
except asyncio.CancelledError:
45+
raise
46+
finally:
47+
d |= 0x20
48+
v = bytes(chr(d), 'utf8')
49+
uart.write(v)
50+
instance -= 1
51+
return res
52+
return wrapped_coro
53+
return decorator
54+
55+
# Optionally run this to show up periods of blocking behaviour
56+
@monitor(0)
57+
async def _do_nowt():
58+
await asyncio.sleep_ms(0)
59+
60+
async def hog_detect():
61+
while True:
62+
await _do_nowt()
63+
64+
# Monitor a synchronous function definition
65+
def mon_func(n):
66+
def decorator(func):
67+
_validate(n)
68+
dstart = 0x40 + n
69+
vstart = bytes(chr(dstart), 'utf8')
70+
dend = 0x60 + n
71+
vend = bytes(chr(dend), 'utf8')
72+
def wrapped_func(*args, **kwargs):
73+
uart.write(vstart)
74+
res = func(*args, **kwargs)
75+
uart.write(vend)
76+
return res
77+
return wrapped_func
78+
return decorator
79+
80+
81+
# Monitor a synchronous function call
82+
class mon_call:
83+
def __init__(self, n):
84+
_validate(n)
85+
self.n = n
86+
self.dstart = 0x40 + n
87+
self.vstart = bytes(chr(self.dstart), 'utf8')
88+
self.dend = 0x60 + n
89+
self.vend = bytes(chr(self.dend), 'utf8')
90+
91+
def __enter__(self):
92+
uart.write(self.vstart)
93+
return self
94+
95+
def __exit__(self, type, value, traceback):
96+
uart.write(self.vend)
97+
return False # Don't silence exceptions

v3/as_demos/monitor/monitor_pico.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# monitor_pico.py
2+
# Runs on a Raspberry Pico board to receive data from monitor.py
3+
4+
# Copyright (c) 2021 Peter Hinch
5+
# Released under the MIT License (MIT) - see LICENSE file
6+
7+
# UART gets a single ASCII byte defining the pin number and whether
8+
# to increment (uppercase) or decrement (lowercase) the use count.
9+
# Pin goes high if use count > 0 else low.
10+
# incoming numbers are 0..22 which map onto 23 GPIO pins
11+
12+
from machine import UART, Pin, Timer
13+
14+
# Valid GPIO pins
15+
# GP0,1 are UART 0 so pins are 2..22, 26..27
16+
PIN_NOS = list(range(2,23)) + list(range(26, 28))
17+
uart = UART(0, 1_000_000) # rx on GP1
18+
19+
pin_t = Pin(28, Pin.OUT)
20+
def _cb(_):
21+
pin_t(1)
22+
print('Hog')
23+
pin_t(0)
24+
25+
tim = Timer()
26+
t_ms = 100
27+
# Index is incoming ID
28+
# contents [Pin, instance_count, verbose]
29+
pins = []
30+
for pin_no in PIN_NOS:
31+
pins.append([Pin(pin_no, Pin.OUT), 0, False])
32+
33+
def run(period=100, verbose=[]):
34+
global t_ms
35+
t_ms = period
36+
for x in verbose:
37+
pins[x][2] = True
38+
while True:
39+
while not uart.any():
40+
pass
41+
x = ord(uart.read(1))
42+
#print('got', chr(x)) gets CcAa
43+
if not 0x40 <= x <= 0x7f: # Get an initial 0
44+
continue
45+
if x == 0x40:
46+
tim.init(period=t_ms, mode=Timer.ONE_SHOT, callback=_cb)
47+
i = x & 0x1f # Key: 0x40 (ord('@')) is pin ID 0
48+
d = -1 if x & 0x20 else 1
49+
pins[i][1] += d
50+
if pins[i][1]: # Count > 0 turn pin on
51+
pins[i][0](1)
52+
else:
53+
pins[i][0](0)
54+
if pins[i][2]:
55+
print(f'ident {i} count {pins[i][1]}')

0 commit comments

Comments
 (0)