In embedded applications, keeping track of time is crucial. Even for the simple task of blinking a led at a certain time interval, we need a reference of time that is constant and precise.
A clock is a piece of hardware that provides us with that reference. Its purpose is to oscillate at a fixed frequency and provide a signal that switches from high to low at a fixed interval.
The most precise type of clock is the crystal oscillator (XOSC). The reason why it is so accurate is because it uses the crystal's natural vibration frequency to create the clock signal. This clock is usually external to the processor itself, but the processor also has an internal clock (ROSC) that is less accurate and that can be used in cases where small variations of clock pulses are negligible. When using the USB protocol, for instance, a more stable clock signal is required, therefore the XOSC is necessary. The crystal oscillator on the Raspberry Pi Pico board has a frequency of 12MHz.
This clock signal is just a reference, and most of the time we need to adjust it to our needs. This is done by either multiplying or dividing the clock, or in other words, elevating or lowering the frequency of the clock. For example, the RP2040 itself runs on a 125MHz clock, so the crystal oscillator frequency of 12MHz is multiplied (this is done using a method called Phase-Locked Loop).
A counter is a piece of hardware logic that counts, as its name suggests. Every clock cycle, it increments the value of a register, until it overflows and starts anew.
The way the counter works here is that it increments/decrements every clock cycle and checks whether or not it has reached its reset value. If is has, then it resets to its initial value and starts all over again.
The ARM Cortex-M uses the SysTick time counter to keep track of time. This counter is decremented every microsecond, and when it reaches 0, it triggers an exception and then resets.
An alarm is a counter that triggers an interrupt every time it reaches a certain value. This way, an alarm can be set to trigger after a specific interval of time, and while it's waiting, the main program can continue executing instructions, and so it is not blocked. When the alarm reaches the chosen value, it goes off and triggers an interrupt that can then be handled in its specific ISR.
Up to now, we learned to turn a led on and off, or in other words, set a led's intensity to 100% or 0%. What if we wanted to turn on the led only at 50% intensity? We only have a two-level digital value, 0 or 1, so technically a value of 0.5 is not possible. What we can do is simulate this analog signal, so that it looks like the led is at half intensity.
Pulse-Width Modulation (PWM) is a method of simulating an analog signal using a digital one, by varying the width of the generated square wave.
The duty cycle of the signal is the percentage of time per period that the signal is high.
$D[\%] = \frac{t\_on}{t\_on + t\_off} \cdot 100 = \frac{pulse\_width}{period} \cdot 100$
Thus, the average voltage reaching the device is given by the relationship: D * Vcc.So if we wanted our led to be at 50% intensity, we would choose a duty cycle of 50%. By quickly switching between high and low, the led appears to the human eye as being at only 50% intensity, when in reality, it's only on at max intensity 50% of the time, and off the rest of the time.
For the RP2040, to generate this PWM signal, a counter is used. The PWM counter is controlled by these registers (X can be from 0-7, depending on the channel):
When CHX_CTR is reset, the value of the output signal is 1. The counter counts up until it reaches CHX_CC, after which the value of the output signal becomes 0. The counter continues to count until it reaches CHX_TOP, and then the signal becomes 1 again. This way, by choosing the value of CHX_CC, we set the duty cycle of the PWM signal.
On RP2040, all GPIO pins support PWM. Every two pins share a PWM slice, and each one of them is on a separate channel.
Here's how to initialize and use PWM on a pin in RP2040 using Pico SDK:
#include <hardware/pwm.h>
const uint count_top = 1000; float output_duty_cycle = 0.2f; uint slice = pwm_gpio_to_slice_num(gpio); pwm_config cfg = pwm_get_default_config(); pwm_config_set_wrap(&cfg, count_top); pwm_init(slice, &cfg, true); gpio_set_function(gpio, GPIO_FUNC_PWM);
pwm_set_gpio_level(gpio, (uint16_t) (output_duty_cycle * (count_top + 1)));
pwm_set_enabled(slice, true);
Now we know how to represent an analog signal using digital signals. There are plenty of cases in which we need to know how to transform an analog signal into a digital one, for example a temperature reading, or the voice of a person. This means that we need to correctly represent a continuous wave of infinite values to a discrete wave of a finite set of values. For this, we need to sample the analog signal periodically, in other words to measure the analog signal at a fixed interval of time. This is done by using an Analog-to-Digital converter.
The ADC has two important parameters that define the quality of the signal representation:
The Nyquist-Shannon sampling theorem serves as a bridge between continuous-time signals and discrete-time signals. It establishes a link between the frequency range of a signal and the sample rate required to avoid a type of distortion called aliasing. Aliasing occurs when a signal is not sampled fast enough to construct an accurate waveform representation.
For an analog signal to be represented without loss of information, the analog signal needs to be sampled at a frequency greater than twice the maximum frequency of the signal. In other words, we must sample at least twice per cycle.
A photoresistor (or photocell) is a sensor that measures the intensity of light around it. Its internal resistance varies depending on the light hitting its surface; therefore, the more light there is, the lower the resistance will be.
The RP2040 microcontroller integrates a 12-bit ADC for converting analog voltage signals from external sensors or circuits into digital values usable by your program, meaning it can represent analog voltages with 4096 (2^12) discrete values. Each input channel can measure voltages between 0V (ground) and the ADC reference voltage (typically the RP2040's internal 3.3V supply, but you can use an external reference for higher accuracy).
By configuring the input mux, you can select the desired channel (external sensor or internal temperature) for conversion. The ADC offers a multiplexer (mux) that allows selecting one of five available input channels:
In order to setup the ADC you need to follow the next steps:
#include <hardware/adc.h>
adc_init(); // Make sure GPIO is high-impedance, no pullups etc adc_gpio_init(26); // Select ADC input 0 (GPIO26) adc_select_input(0);
// 12-bit conversion, assume max value == ADC_VREF == 3.3 V const float conversion_factor = 3.3f / (1 << 12); uint16_t result = adc_read();
For the internal temperature sensor able to read the MCU temperature you can have a look over the following example: onboard_temperature
The RP2040 goes beyond traditional microcontrollers by offering two Programmable I/O (PIO) blocks. These PIOs are essentially mini-state machines that can execute custom instructions, allowing you to precisely control hardware interactions without relying solely on the main CPU.
Some of the benefits for PIOs include:
PIO is programmable in the same sense as a processor. There are two PIO blocks with four state machines each, that can independently execute sequential programs to manipulate GPIOs and transfer data. Unlike a general purpose processor, PIO state machines are highly specialized for IO, with a focus on determinism, precise timing, and close integration with fixed-function hardware. Each state machine is equipped with:
It can be used for multiple purposes such as: generate a precise square wave at a specific frequency on a GPIO pin, ideal for driving LEDs or communication protocols, manipulating data bits in and out of GPIO pins, a PIO can emulate a shift register for interfacing with serial devices or handle complex communication protocols like SPI (Serial Peripheral Interface) or I2C (Inter-Integrated Circuit) by generating the necessary control signals for data transfer.
The four state machines execute from a shared instruction memory. System software loads programs into this memory, configures the state machines and IO mapping, and then sets the state machines running. PIO programs come from various sources: assembled directly by the user, drawn from the PIO library, or generated programmatically by user software.
From this point on, state machines are generally autonomous, and system software interacts through DMA, interrupts and control registers, as with other peripherals on RP2040. For more complex interfaces, PIO provides a small but flexible set of primitives which allow system software to be more hands-on with state machine control flow.
PIO state machines execute short, binary programs. Programs for common interfaces, such as UART, SPI, or I2C, are available in the PIO library, so in many cases, it is not necessary to write PIO programs. However, the PIO is much more flexible when programmed directly, supporting a wide variety of interfaces which may not have been foreseen by its designers.
The PIO has a total of nine instructions: JMP, WAIT, IN, OUT, PUSH, PULL, MOV, IRQ, and SET. Though the PIO only has a total of nine instructions, it would be difficult to edit PIO program binaries by hand. The PIO assembler is included with the SDK, and is called pioasm. This program processes a PIO assembly input text file, which may contain multiple programs, and writes out the assembled programs ready for use. For the SDK these assembled programs are emitted in form of C headers, containing constant arrays.
The following directives control the assembly of PIO programs:
For more information on values, expressions, labels and instructions supported by PIO please consult the datasheet at chapter 3.3. For example on how to use PIO consult the pico pio examples.