
In a previous post, I looked at ways of measuring frequency accurately using an RP2040 CPU programmed in C. In this project, I have re-coded those functions in MicroPython, and provided a few enhancements.
I use Direct Memory Access (DMA) to minimise the CPU workload, and avoid the need for interrupts, but this raises a problem: when programming in C, there is a substantial Application Programming Interface (API) that allows everything on the RP2040/2350 chip to be configured using simple function calls. However, MicroPython aims to be more-or-less compatible with a wide range of CPUs, so not much CPU-specific code has been included. This means that we have to do some low-level programming if we want to use the more obscure functions of the Pulse Width Modulation (PWM) peripheral.
My first attempt at ADC and DMA low-level programming used MicroPython ‘uctypes’ to create a model of the peripherals, and if you are interested in the nuts-and-bolts of this method, I suggest you browse the post here.
This approach produced excellent results, but wasn’t very ‘pythonic’, so I’ve encapsulated common API functions in Python classes; when choosing function names, I’ve copied those in the C API when possible. So for example, setting the PWM peripheral in C is:
#define PWM_GPIO_PIN 3
uint slice = pwm_gpio_to_slice_num(PWM_GPIO_PIN);
pwm_config cfg = pwm_get_default_config();
pwm_config_set_clkdiv_mode(&cfg, PWM_DIV_B_RISING);
// ..and any other settings, then..
pwm_init(slice, &cfg, false);
In MicroPython this is done by instantiating a device from the PWM class:
import pico_devices as devs
PWM_GPIO_PIN = 3
pwm = devs.PWM(PWM_GPIO_PIN)
pwm.set_clkdiv_mode(devs.PWM_DIV_B_RISING)
# ..and any other settings..
Not only are the function call names very similar between C and Python, but also the underlying code is similar; for example, the set_clkdiv function is defined as:
class PWM:
def set_clkdiv_mode(self, mode):
self.slice.CSR.DIVMODE = mode
where ‘slice’ is the (somewhat unusual) name for a PWM channel, CSR is the Control and Status Register, and DIVMODE is a 2-bit field within that register. If you want to learn more about the internal structure of the PWM peripheral, I suggest you take a look at the C language post.
The Python classes have been kept very ‘lean’, with no error-checking of the parameters; when time permits, I intend to create a sub-class that provides comprehensive parameter-checking .
The initial version of the post was RP2040-only; for the RP2350 CPU on the Pico2 the underlying logic is the same, but a few details had to be changed, as detailed towards the end of this post.
Test signal
We need a waveform for the tests, which the RP2040 PWM peripherals can easily provide. The following code generates a 100 kHz square wave on GPIO pin 4:
PWM_OUT_PIN = 4 # GPIO pin for output
PWM_DIV = 125 # 125e6 / 125 = 1 MHz
PWM_WRAP = 9 # 1 MHz / (9 + 1) = 100 kHz
PWM_LEVEL = (PWM_WRAP+1)//2 # 50% PWM
# Start a PWM output
def pwm_out(pin, div, level, wrap):
devs.gpio_set_function(pin, devs.GPIO_FUNC_PWM)
pwm = devs.PWM(pin)
pwm.set_clkdiv_int_frac(div, 0)
pwm.set_wrap(wrap)
pwm.set_chan_level(pwm.gpio_to_channel(pin), level)
pwm.set_enabled(1)
return pwm
test_signal = pwm_out(PWM_OUT_PIN, PWM_DIV, PWM_LEVEL, PWM_WRAP)
The choice of GPIO pin number is quite arbitrary, you just need to be aware when programming PWM that there are are 16 channels (technically 8 slices, each with 2 channels) mapped onto 32 pins, so if I’m using GPIO pin 4 for this signal, I can’t use PWM on GPIO 20. However, that pin is still available for any other purpose, so the limitation usually isn’t a problem.
16-bit counter

The RP2040 PWM peripheral can also act as a pulse counter, and by counting the number of edges in a given time, we can establish the frequency. This pulse input capability is only available on odd GPIO pin numbers (i.e. channel B of a PWM ‘slice’).
The code is quite simple; it is only necessary to set the mode, and the frequency divisor:
# Initialise PWM as a pulse counter (gpio must be odd number)
def pulse_counter_init(pin, rising=True):
devs.gpio_set_function(pin, devs.GPIO_FUNC_PWM)
ctr = devs.PWM(pin)
ctr.set_clkdiv_mode(devs.PWM_DIV_B_RISING if rising else devs.PWM_DIV_B_FALLING)
ctr.set_clkdiv(1)
return ctr
Now we can make a simple frequency meter, by clearing the count to zero, then enabling the counter for a specific period of time:
# Enable or disable pulse counter
def pulse_counter_enable(ctr, en):
if en:
ctr.set_ctr(0)
ctr.set_enabled(en)
# Get value of pulse counter
def pulse_counter_value(ctr):
return ctr.get_counter()
pulse_counter_enable(counter, True)
time.sleep(0.1)
pulse_counter_enable(counter, False)
val = pulse_counter_value(counter)
print("Sleep 0.1s, count %u" % val)
The count-value for a 100 kHz signal and a 100 millisecond sleep is theoretically 10000, but is actually around 10015, due to the time-delays associated with the Python instructions. I’ll be describing ways to eliminate these delays later on in this post.
32-bit counter
Unfortunately the PWM peripheral has only a 16-bit counter, which can be too short for high-frequency signals or long sample-times. In the C code I polled the count value, to count the number of times it rolled over past 65535, then add on that number to the final result.
An alternative method is to take advantage of the fact that the DMA controller has a 32-bit down-counter that decrements every time a DMA cycle is completed. So we just need to set the DMA count to a large number, and set the PWM peripheral to trigger a DMA cycle on every rising or falling edge of the input signal.
This prompts the question “what data should the DMA transfer?” and the answer is “anything: it doesn’t matter”, but we still have to be careful to ensure the transfers don’t over-write some random area in memory. We need to very carefully specify the DMA source & destination addresses, using the binary ‘array’ data type, which occupies a fixed area of memory, without all the complexities of a Python ‘object’. The syntax to create the array is somewhat unusual; rather than just specifying a size, we have to provide the initial data using an iterator. The pico_devices library provides a helper function to simplify the process:
# Create 32-bit array (to receive DMA data)
def array32(size):
return array.array('I', (0 for _ in range(size)))
To use this array with DMA, we have to get its address in memory, using ‘uctypes.addressof’:
# Get address of variable (for DMA)
def addressof(var):
return uctypes.addressof(var)
Armed with these functions, we can initialise the DMA controller:
ext_data = devs.array32(1) # Dummy array for extended counter
# Use DMA to extend pulse counter to 32 bits
def pulse_counter_ext_init(ctr):
ctr.set_enabled(False)
ctr.set_wrap(0)
ctr.set_ctr(0)
ctr_dma = devs.DMA()
ctr_dma.set_transfer_data_size(devs.DMA_SIZE_8)
ctr_dma.set_read_increment(False)
ctr_dma.set_write_increment(False)
ctr_dma.set_read_addr(devs.addressof(ext_data))
ctr_dma.set_write_addr(devs.addressof(ext_data))
ctr_dma.set_dreq(ctr.get_dreq())
ctr.set_enabled(True)
return ctr_dma
We’re modifying the PWM 16-bit counter to wrap around to zero on every input edge, and initialising its starting value to zero, which is essential. Then a DMA channel is instantiated, and configured to copy 8 bits from the data array back to the data array.
It is important to note that enabling the DMA counter does not necessarily start the transfer from scratch; if a transfer has already started, the controller will resume counting where it left off. So it is necessary to clear out any existing transfer, before stating a new one:
# Start the extended pulse counter
def pulse_counter_ext_start(ctr_dma):
ctr_dma.abort()
ctr_dma.set_trans_count(0xffffffff, True)
# Stop the extended pulse counter
def pulse_counter_ext_stop(ctr_dma):
ctr_dma.set_enable(False)
# Return value from extended pulse counter
def pulse_counter_ext_value(ctr_dma):
return 0xffffffff - ctr_dma.get_trans_count()
Using the extended pulse counter is similar to the 16-bit counter, we just need to remember the limitation that if the 32-bit value is exceeded, the counter will stop, and not wrap around.
counter_dma = pulse_counter_ext_init(counter)
pulse_counter_ext_start(counter_dma)
time.sleep(1.0)
val = pulse_counter_ext_value(counter_dma)
pulse_counter_ext_stop(counter_dma)
print("Sleep 1.0s, ext count %u" % val)
A typical response is:
Sleep 1.0s, ext count 100011
This shows that the count is not limited to 16 bits.
Gating a counter
To accurately count pulses with a specific time-frame, it is necessary for the timing to be accurate, and as we have seen, the ‘sleep’ function introduces a significant error. To eliminate this, we need a hardware mechanism whereby a timer directly enables and disables the counter (a process called ‘gating’) without requiring any intervention from the CPU.
This involves programming another PWM channel to act as a timer, then when the time has expired, using DMA to modify the counter’s register to stop counting. The default PWM clock is 125 MHz, the prescaler is 8 bits, and the counter register is 16 bits, effectively 17 bits if we engage ‘phase correct’ mode. So the slowest gate frequency is 125e6 / (256 * 65536 * 2) = 3.725 Hz, a gate-time of 0.268 seconds; I’ve opted for 0.25 seconds.
GATE_TIMER_PIN = 0 # Used to define PWM slice
GATE_PRESCALE = 250 # 125e6 / 250 = 500 kHz
GATE_WRAP = 125000 # 500 kHz / 125000 = 4 Hz (250 ms)
# Initialise PWM as a gate timer
def gate_timer_init(pin):
pwm = devs.PWM(pin)
pwm.set_clkdiv_int_frac(GATE_PRESCALE, 0)
pwm.set_wrap(int(GATE_WRAP/2 - 1))
pwm.set_chan_level(pwm.gpio_to_channel(pin), int(GATE_WRAP/4))
pwm.set_phase_correct(True)
return pwm
This code uses GPIO pin 0 to identify which PWM ‘slice’ is being used. Since we aren’t enabling PWM I/O on that pin, it can still be used for any other function, such as serial output.
We need to trigger a DMA cycle when the gate PWM times out; that cycle will be used to disable the counter PWM. So when initialising the DMA channel, we need to capture the non-enabled state, that will be written into the counter CSR register; this is only one 32-bit value, but I’m using a fixed array for that value, since DMA can’t handle the complexities of Python object storage.
gate_data = devs.array32(1)
# Initialise gate timer DMA
def gate_dma_init(ctr, gate):
dma = devs.DMA()
dma.set_transfer_data_size(devs.DMA_SIZE_32)
dma.set_read_increment(False)
dma.set_write_increment(False)
dma.set_dreq(gate.get_dreq())
gate_data[0] = ctr.slice.CSR_REG
dma.set_read_addr(devs.addressof(gate_data))
dma.set_write_addr(ctr.get_csr_address())
return dma
To start the frequency measurement, it is necessary to set the DMA count (since this is reset by a DMA cycle), enable DMA, then enable the gate & counter PWM devices simultaneously.
# Start frequency measurment using gate
def freq_gate_start(ctr, gate, dma):
ctr.set_ctr(0)
gate.set_ctr(0)
dma.set_trans_count(1, True)
ctr.set_enables((1<<ctr.slice_num) | (1<<gate.slice_num), True)
If all is well, the test signal frequency should be reported correctly on the console:
Gate 250.0 ms, count 25000, freq 100.0 kHz
Edge timer

An alternative method for measuring frequency is to measure the times between the rising or falling edges, producing values that are the reciprocal of the frequency. This is particularly useful when dealing with slow signals, or if you want to implement a method to eliminate ‘rogue’ pulses, since you get one measurement for each cycle of the input signal, so could reject pulses that are outside the expected frequency range.
By now, you won’t be surprised to learn that I use DMA to capture the time-value for each edge; there is a convenient 32-bit microsecond-value that can be copied into a suitably-sized array on edge positive or negative edge; the sole function of the PWM peripheral is to detect the edge, and trigger a DMA cycle.
We’ll be generating a test signal as before, but this time it is 10 Hz:
PWM_DIV = 250 # 125e6 / 125 = 500 kHz
PWM_WRAP = 50000 - 1 # 500 kHz / 50000 = 10 Hz
The PWM peripheral initialisation is similar to previous functions:
# Initialise PWM as a timer (gpio must be odd number)
def timer_init(pin, rising=True):
devs.gpio_set_function(pin, devs.GPIO_FUNC_PWM)
pwm = devs.PWM(pin)
pwm.set_clkdiv_mode(devs.PWM_DIV_B_RISING if rising else devs.PWM_DIV_B_FALLING)
pwm.set_clkdiv(1)
pwm.set_wrap(0);
return pwm
The DMA controller is also similar, but the destination is set to auto-increment with every transfer:
# Initialise timer DMA
def timer_dma_init(timer):
dma = devs.DMA()
dma.set_transfer_data_size(devs.DMA_SIZE_32)
dma.set_read_increment(False)
dma.set_write_increment(True)
dma.set_dreq(timer.get_dreq())
dma.set_read_addr(devs.TIMER_RAWL_ADDR)
return dma
Starting the DMA is a bit different; firstly we need to use the ‘abort’ command to stop any previous transfer that might still be in progress. If we didn’t do that, and just enabled DMA, the transfer would resume where it left off – the new settings would be ignored. Secondly we need to set both the transfer count and the destination address, since these will have been changed by a previous transfer.
# Start frequency measurment using gate
def timer_start(timer, dma):
timer.set_ctr(0)
timer.set_enabled(True)
dma.abort()
dma.set_write_addr(devs.addressof(time_data))
dma.set_trans_count(NTIMES, True)
The main program needs to perform some simple maths to derive the frequency, guarding against the possibility that there may be insufficient time-values (1 or less):
NTIMES = 9 # Number of time samples
time_data = devs.array32(NTIMES) # Time data
timer_pwm = timer_init(PWM_IN_PIN)
timer_dma = timer_dma_init(timer_pwm)
timer_start(timer_pwm, timer_dma)
time.sleep(1.0)
timer_stop(timer_pwm)
count = NTIMES - timer_dma.get_trans_count()
data = time_data[0:count]
diffs = [data[n]-data[n-1] for n in range(1, len(data))]
total = sum(diffs)
freq = (1e6 * len(diffs) / total) if total else 0
print("%u samples, total %u us, freq %3.1f Hz" % (count, total, freq))
The frequency of the test signal should be displayed on the console:
9 samples, total 800000 us, freq 10.0 Hz
Running the code
The source files are available on Github here. It is necessary to load the library file ‘pico_devices.py’ onto the target system; if you are using the Thonny editor, this is done by right-clicking the filename, and selecting ‘Upload to /’. You can then run one of the program files to measure frequency:
- pico_counter.py: use simple pulse counting and sleep timing
- pico_freq.py: use DMA to accurately gate the pulse counter
- pico_timer.py: use time-interval (reciprocal) measurement
Don’t forget to link GPIO pin 4 (the test signal output) to pin 3 (the measurement input) for these tests.
Pico2 RP2350 processor
Some minor changes had to be made to accommodate the newer processor; for simplicity, the code in pico_devices.py auto-detects which platform it is running on, and alters the settings accordingly.
Identifying the CPU
There is a Micropython library function uos.uname() that provides CPU identification. Here is the output, running on the latest Micropython versions at the time of writing (the full Pico2 release isn’t available yet):
# Response on RP2040 Pico
(sysname='rp2', nodename='rp2', release='1.23.0', version='v1.23.0 on 2024-06-02 (GNU 13.2.0 MinSizeRel)', machine='Raspberry Pi Pico with RP2040')
# Response on RP2350 Pico2
(sysname='rp2', nodename='rp2', release='1.24.0-preview', version='v1.24.0-preview.321.g0ff782975 on 2024-09-23 (GNU 14.1.0 MinSizeRel)', machine='Raspberry Pi Pico2 with RP2350')
Since the code should be compatible with any board that uses an RP2350, I decided to set a boolean variable by checking the ‘machine’ string for the number ‘2350’, e.g.
PICO2 = "2350" in uos.uname().machine
Clock frequency
The default clock frequency for the Pico2 is 150 MHz, as opposed to 125 MHz for the RP2040. This increase highlights an unfortunate problem with the divisor values I’ve previously chosen; to get a 250 ms gate-time, I prescaled the clock frequency by 250 to get 500 kHz, then divided by 125000 to get 4 Hz. However, this doesn’t work with the higher clock frequency, since the prescaler is restricted to an 8-bit value, and the divisor to a 17-bit value (since I’m using ‘phase correct’ mode); the lowest frequency I can get on the Pico2 is 4.49 Hz, so I’m using 5 Hz:
# Frequency gate settings for RP2350 and RP2040
# Prescale must be < 256, and wrap < 131072 (phase correct mode)
if devs.PICO2:
GATE_PRESCALE = 250 # 150e6 / 250 = 600 kHz
GATE_WRAP = 120000 # 600 kHz / 120000 = 5 Hz (200 ms)
else:
GATE_PRESCALE = 250 # 125e6 / 250 = 500 kHz
GATE_WRAP = 125000 # 500 kHz / 125000 = 4 Hz (250 ms)
GATE_FREQ = devs.CLOCK_FREQ / (GATE_PRESCALE * GATE_WRAP)
GATE_TIME_MSEC = 1000 / GATE_FREQ
Peripheral changes
Although the structure of the CPU peripherals remains unchanged, there are various changes to the details. The most obvious of these are the base addresses:
if PICO2:
CLOCK_FREQ = 150e6
GPIO_BASE = 0x40028000
PAD_BASE = 0x40038000
ADC_BASE = 0x400a0000
PWM_BASE = 0x400a8000
TIMER_BASE = 0x400b0000
DMA_BASE = 0x50000000
else:
CLOCK_FREQ = 125e6
GPIO_BASE = 0x40014000
PAD_BASE = 0x4001c000
ADC_BASE = 0x4004c000
PWM_BASE = 0x40050000
TIMER_BASE = 0x40054000
DMA_BASE = 0x50000000
There are also significant changes to constants, such as the DMA requests:
if PICO2:
DREQ_SPI0_TX, DREQ_SPI0_RX, DREQ_SPI1_TX, DREQ_SPI1_RX = 24, 25, 26, 27
DREQ_UART0_TX, DREQ_UART0_RX, DREQ_UART1_TX, DREQ_UART1_RX = 28, 29, 30, 31
DREQ_PWM_WRAP0,DREQ_PWM_WRAP1,DREQ_PWM_WRAP2,DREQ_PWM_WRAP3= 32, 33, 34, 35
DREQ_ADC = 48
else:
DREQ_SPI0_TX, DREQ_SPI0_RX, DREQ_SPI1_TX, DREQ_SPI1_RX = 16, 17, 18, 19
DREQ_UART0_TX, DREQ_UART0_RX, DREQ_UART1_TX, DREQ_UART1_RX = 20, 21, 22, 23
DREQ_PWM_WRAP0,DREQ_PWM_WRAP1,DREQ_PWM_WRAP2,DREQ_PWM_WRAP3= 24, 25, 26, 27
DREQ_ADC = 36
Refer to pico_devices.py to see all the changes, but in practice the differences can be ignored, as the values change automatically, based on identification of the CPU.
Copyright (c) Jeremy P Bentham 2024. Please credit this blog if you use the information or software in it.









































