Input / Output¶
Learning goals: Programmatic usage of I/O pins for different use cases in an embedded device
Input / Output, or also known as I/O means transfering information between the components of a computer and its peripherals. Inputs are the inputs / signals / data received by the component, like a button press, or a message from a peripheral device. Outputs are signals or information sent by the component, like a control command to a peripheral device.
Usually, physical components and the logic required to control them through I/O are integrated to the microcontroller or its board. Below is a list of typical components, some of which we are going to use in this course.
- Push buttons, slider, touch screen
- LEDs
- Timer circuit
- Analog-to-digital converter (AD-converter, ADC)
- PWM circuit (Pulse Width Modulation)
- Circuits for serial communication, for example UART, SPI or I2C
- Non-volatile memory, like EPROM
- USB and/or Ethernet controllers
- LCD-screen
Peripheral devices are either digital or analog. Digital components are often more complex, they have different buses, and bit values (eg. pre-defined voltage levels) are used to communicate with them.
Analog components output different voltage levels, that can be anything between ground and operating voltage (for example 0-3.3V). As an example, many simple sensors output their measurement result as an analog signal. For a computer, these signals need to be first converted into digital form or more simply put, into numerical values, after which they can be handled numerically. Specifically for this task, there are analog-to-digital converters integrated into microcontrollers, that produce (rounded) numerical values in pre-defined resolution from input voltage. The resolution can be for example 8-bit, in which case the range would be values 0-255. In the case of the Pico, the resolution is 12 bits, so we can have from 0 to 4095 values.
Now, the task of the programmer is to convert the value given by AD-converter into a value that corresponds with a measurable quantity of the physical world, after which it can be handled in the program.
Examples of this kind of sensors are different temperature / air pressure / light sensors and also a microphone. If we assume the measurement range of a temperature sensor to be 0-100C, the programmer would have to create a conversion function that converts the values from AD-converters value (e.g. 0-255) to temperature (0-100C). After that, we could tell the user that the temperature of the room is for example +21C by using the screen of the device.
The sensors integrated to the extension board (HAT) of Pico are mainly digital, so we are not going to cover the use of the ADC in the basic material.
Pin¶
Okay, let's look at the basic stuff first. A pin means a physical leg or connector of a microchip / a component. The purpose of pins / leads is to connect the component to the board both electronically and mechanically. Each of the pin of a circuit have a certain use. Pin layout visualizes, what or which are the uses of each pin. As we can see, sometimes one pin can have multiple uses, depending on the functionalities of the device. This way, the functions of a device are restricted so that we can only use some of its functionalities at the same time.
Below as an example is the pin layout for Intel 4004, the world's first commercial microprocessor from 1971.
Pins and bits¶
And again some revision from the Bitwise operations-lecture material. We should keep in mind the common principle that in program code, each of the peripheral device's, or now also microcontroller's pin corresponds to one bit. For the handling of these bits, we have the bitwise operations of C.
- By setting a bit to logical one or zero, the voltage level of a physical pin will change. This is how we can programmatically control a pin and the peripheral device connected to it.
- Conversely, we can read the state of a pin (in other words voltage level) and the corresponding bit will be set to appropriate logical value
- Logical one (High) usually corresponds with operating voltage (commonly 3.3V or 5V) or a voltage in agreed range. Usually voltages larger than 2.0-2.7V are considered to be a one
- Logical zero (Low) corresponds with ground 0V, or usually voltages smaller than 0.7V
Pins are given a logical name to make the job of a programmer (and an electronics designer) easier, for example
SWCLK, , RUN (for reset) or GPIO16 for the GPIO General use pin 16. These names correspond with the names in peripheral device libraries most of the time, so that there exists a constant which is used to perform the bitwise operations required.An example. We reset a device by zeroing bit
RUN from the control register of a device. (Usually, resetting a device programmatically is not this straightforward, but we got and exiting example at least...) control_register = control_register & ~(1 << RUN); //Clears the run bit in the register
Some of the pins are reserved for general use (General Purpose I/O, GPIO) and the use case of these pins can be freely defined by the programmer, of course taking into account the peripheral devices that are to be connected to these pins. Logically, in microcontrollers, GPIO-pins are sometimes grouped into I/O-ports, so that 8 pins are logically thought of as one port. This is useful if a device needs numerous I/O-buses, as they can be logically handled as one unit and controlling them is easy with binary values. However, Pico thinks of all its I/O-ports separately, if the programmer does not excplicitly define the logical ports for desired pins. This is not needed in the course project.
The following figures show the pinout of the RP2040 chip (the microcontroller inside the Raspberry Pi Pico) and the pinout of the Raspberry Pi Pico board itself.
Earlier we mentioned that some pins have dedicated purposes, while others can be used for general input/output (I/O) to connect external devices. The RP2040 is a bit more flexible: it does not assign a fixed pin to every peripheral (see all peripherals in the |architecture diagram). Instead, many pins can be configured for different functions.
This explains why, in the Raspberry Pi Pico pinout, you see that most pins list multiple possible functions. But how can a single pin serve different roles? The answer is that in the RP2040 (and also the RP2350) the chip allows us to “redirect” or connect a peripheral to one of several possible pins. In other words, we can decide which role a pin takes at a given time.
For example, suppose we want to use UART. (We will study UART later in the course, but for now just remember that it provides two-way serial communication between devices, using two lines: one for transmit and one for receive.) On the Pico, you can map UART to different pin pairs: such as pins 4 and 5, or pins 8 and 9, or pins 20 and 21.
Memory-mapped I/O¶
The meaning of memory-mapped I/O was that we reserve memory positions from the main memory of the device to function as (device)registers, that are connected to the pins of a peripheral device. We can control the bits of these memory positions via variables, which allows us to interact with the device from our program. These memory positions can be thought to form the data, address and control buses, in such a capacity in which they are needed by the device:
- Now we are utilizing pointers by initializing them to point to the memory positions reserved for the peripheral device. The addresses of these memory positions are agreed on in the design phase of the device, and when a library is created for the peripheral device, they are set to their places in the process.
- From code, we can control peripheral devices by handling the bits corresponding to them in their memory positions. We can also set control bits and read the state of the device from them.
- As a part of the control bus, we might also have an interrupt pin. The signal sent by this pin (an interrupt) can be captured in our program and acted upon accordingly. Well, we will discuss more about interruptions in a second.
- Similarly, in a more complex device we pick a memory position/positions to be the data bus. These positions are connected associated with the data bus (e.g. pins), and using them, we can transmit data to the device as binary values.
- In some more complicated peripheral devices like displays, there can be additionally a separate address bus. The memory positions of this address bus are internal memory positions of the device. Exciting!!!
Before looking at the different types of registers, it is important to clear up a possible misunderstanding. When we say that peripherals are memory-mapped, this does not mean that the peripherals are given RAM to store data. What we mean is that the peripherals are placed in the CPU’s addressable memory space. In practice, this allows the CPU to read and write to them using the same instructions it uses for RAM access. Behind the scenes, the system bus and address decoder make sure that each memory address is directed to the correct hardware: either actual RAM, Flash, or a peripheral. For peripherals, the registers is not using system RAM, but small hardware elements such as flip-flops or latches that hold configuration bits or reflect the current hardware state.
After this incise, let's go back to the type of registers. So, there are three types of registers: address, control and data registers. Peripheral device can provide many registers that fall into one of these categories, whichever combination of these three types is possible, depending on the implementation of the device itself. In Pico, to control some very complex peripheral device, we might need tens of registers. For this reason, it is indeed very relieving to use ready-made libraries to play with these devices!
We need to know the following things about all of these registers:
- Purpose, functionality, and how they are operated
- Memory addresses the registers are located in, and does the compiler environment provide the corresponding constants and pointers.
- The purpose of each single bit of every register
As an example, let’s look at one register layout from the Raspberry Pi RP2040. This register belongs to the on-chip voltage regulator, which generates the DVDD supply used by the chip’s digital logic.
DVDD powers the processor core and other digital blocks inside the RP2040, and it normally runs at about 1.1V. This is much lower than the 3.3V used by the chip’s I/O pins, because the digital logic does not need as high a voltage. The on-chip regulator allows DVDD to be generated internally from IOVDD (the 3.3V I/O supply) or from another supply between 1.8V and 3.3V. From the diagram we can see that the size of the register is 32 bits (
31-0). Several bits are reserved, but some fields contain useful information:- Bits
4-7form the field VSEL, which selects the DVDD voltage level. - Bit
1is the HIZ bit that sets the regulator to high impedance mode, allowing external regulator - Bit
0is the ENABLE bit that turns the regulator on or off (and hence do a chip reset). - Other bits are reserved for internal use .
If we want to change the DVDD voltage, we would write to the VSEL field of this memory-mapped register. To enable or disable the regulator, we would modify the HIZ bit. Just like with other peripherals, this is done by reading and writing the register at its assigned address.
Let's supposse that we want to modify the voltage to 1.15 (that is, writing 1100 in VSEL). The Pico SDK provides the
hw_write_masked helper function to safely update only the relevant bits of a register:hw_write_masked(
(io_rw_32 *)(VREG_AND_CHIP_RESET_BASE + VREG_AND_CHIP_RESET_VREG_OFFSET), // address of VREG
(VREG_AND_CHIP_RESET_VREG_VSEL_1_15 << VREG_AND_CHIP_RESET_VREG_VSEL_LSB), // value 1100 shifted into place -> 4 positions
VREG_AND_CHIP_RESET_VREG_VSEL_BITS // mask for bits [7:4]
);
In this example, the base address
VREG_AND_CHIP_RESET_BASE plus the offset VREG_AND_CHIP_RESET_VREG_OFFSET gives the exact address of the VREG register. The symbolic constant VREG_AND_CHIP_RESET_VREG_VSEL_1_15 corresponds to the binary value 1100, which selects a core voltage of 1.15 V. The macro VREG_AND_CHIP_RESET_VREG_VSEL_LSB is 4, meaning that the VSEL field starts at bit 4, so the value must be shifted into the correct position. Finally, VREG_AND_CHIP_RESET_VREG_VSEL_BITS provides the write mask that ensures only bits [7:4] are modified, leaving all other register bits untouched.But do not worry if it looks very complicated. Actually, it is!!! you usually do not need to go that deeper and the SDK API would offer much easier methods, without the need to have to check all the register values. In our case:
#include "hardware/vreg.h
int main(void){
vreg_set_voltage(VREG_VOLTAGE_1_1_15) //set DVDD to 1.15 V
}
This is not that difficult, is it?
Datasheet¶
The manual or datasheet of a component or a microcontroller explains in great detail every internal functionality and integrated circuit of the device. Datasheet functions as reference when programming with the hardware.
A datasheet is most of the time English-language, strongly based on professional vocabulary and hundreds or thousands of pages long, when talking about more complex devices. Just the datasheet of a normal, 8-bit Arduino microcontroller is just about 300 pages long! So when thinking about how simple controlling a pin is with Arduino, it is surprising to know that behind those operations are tens of pages of material in the datasheet, most of which the programmer does not need to know anything about. The datasheets of more complex devices are, almost without exception, even more broad. For example, the datasheet of the microcontoller in our Pico, RP2040, is 644 pages long!
Fortunately, instead of browsing through hundreds of pages of datasheets, ready-made libraries, functions, macros and constants are available for development environments, where the management of peripheral devices is already implemented and ready for higher-level functionalities.
A peek into Pico¶
In the image below, you can see a block diagram of the functionalities of Pico's microcontroller (RP2040). As we can see, Pico is pretty sophisticated device with plenty of functionalities. Two ARM Cortex-M0+ cores are integrated into it, along with 264 kilobytes of SRAM memory for program execution. The microcontroller manages many peripheral devices, for example two UART serial communications circuits, clock circuit and two I2C-buses. These will be discussed in greater detail in later materials.
Below is the pin layout of Pico W. Pins are given a logical name, for example
VSYS or GPIO16 to make the job of a programmer (and an electronics designer) a little bit easier.Most of the pins are assigned for general purpose (General Purpose I/O, GPIO) and the use cases of these pins are up to the programmer to decide, of course considering the peripheral devices connected to these pins. In Pico, some of the GPIO pins are also assigned special purposes at the time of designing the device, and by using these pins, we could initialize a serial communications circuit and receive or send I2C-data.
In Pico, the libraries provided with the microcontroller give us ready-made functions to programmatically use the pins and peripheral devices connected to them. Below is and example of Pico SDK's function calls in
gpio.h header file:void gpio_init(uint gpio); //Prepare a GPIO for use
...
void gpio_deinit(uint gpio); //Release resources. No more in use
...
static inline bool gpio_get(uint gpio) { //Get the value of the pin.
...
}
Using I/O pins¶
Next, we will dive into the usage of I/O-pins in Pico with the help of a code example. The constants for the extension board of Pico are available in the
pins.h header file added into our project.Our example below, in all its beauty, uses the button of the extension board hat as an on/off-switch for the LED. So, here we need to define two pins for the use of our program: the pin corresponding to the button and the pin for LED.
We will use the
gpio-library provided by Pico SDK. Because some known I/O-pins are connected in the extension board to the button and the LED, we can include them in the program with a header file pins.h. To include a button in the program, we need to do these three things:- Initialize the pins that control the button and the LED
- This happens with the function
gpio_init()from thegpio-library - Create an interruption handler for the button press
- Below is an interruption handler function
buttonFxn - We will cover interruptions in more detail in near future. So will easy to understand.
- and of course, in the
main-function we enable the pin corresponding to the button with the help of a library function - An interruption handler is assigned with the function
gpio_set_irq_enabled_with_callbackfrom the gpio-library
So, every time a button is pressed, this example program runs the function
buttonFxn, where the pin corresponding to the LED changes its state, and which this way drives the LED of the extension board on / off. The example is explored in detail below.#include <FreeRTOS.h>
#include <pico/stdlib.h>
#include <task.h>
#include <stdio.h>
#include <inttypes.h>
#include <pins.h>
// Interruption handler function for button press
void buttonFxn(uint gpio, uint32_t eventMask) {
// We change the state of the LED using negation
uint8_t pinValue = gpio_get(LED1);
pinValue = !pinValue;
gpio_put(LED1, pinValue);
}
int main(void) {
stdio_init_all();
// Initializing pins
gpio_init(BUTTON1);
gpio_set_dir(BUTTON1, GPIO_IN);
gpio_init(LED1);
gpio_set_dir(LED1, GPIO_OUT);
// We set the buttonFxn as an interruption handler
// for the button pin
gpio_set_irq_enabled_with_callback(BUTTON1, GPIO_IRQ_EDGE_RISE, true, buttonFxn);
vTaskStartScheduler();
return 0;
}
Let's review this example step by step
SDK constants for the use of pins¶
Like mentioned before, the SDK of Pico's extension board provides us with a bunch of constants in the
pins.h-header file. By using these constants we can use the different peripheral devices of the extension board. In this case, we are using the constants BUTTON1 and LED1 from the library.Pin initialization¶
Next, we will initialize the pins we are going to use as inputs or outputs. The gpio-library of Pico SDK provides the constants and functions to perform this task. The constant
GPIO_IN tells to the library function gpio_set_dir that we want to set a pin as an input. With the constant GPIO_OUT, we could set a pin as an output. gpio_init(BUTTON1);
gpio_set_dir(BUTTON1, GPIO_IN);
gpio_init(LED1);
gpio_set_dir(LED1, GPIO_OUT);
Here, we will begin by initializing a pin with a constant from the library
pins.h, so that we can tell Pico, which pins do we want to use in the program. When the initialization is done, we tell Pico, in which way we want to use the pins we just initialized. Here we set the pin as input or output with the library function gpio_set_dir. This function takes in as arguments the pin we want to set as input/output, and a constant, that defines the direction for that specific pin.Pin interruption handler¶
For pins set as inputs, we (generally) need a handler function, which defines the actions that are performed when the button is pressed, and an interruption is caused. For this, we have the handler function
buttonFxn in our program.void buttonFxn(uint gpio, uint32_t eventMask) {
// We change the state of the LED using negation
uint8_t pinValue = gpio_get(LED1);
pinValue = !pinValue;
gpio_put(LED1, pinValue);
}
The function works in a following way: Firstly, we read the state of the LED-pin (on "1" / off "0") with the function
gpio_get, and save the result to variable pinValue. This commonly used function needs as an argument the pin we want to read, in this case LED1. After this, the value is negated, which in practice means that we change the state of the LED to be on / off. The new state is then set as the current state of the LED with the function gpio_put.Including the interruption to our program¶
Then we will go back to the function
main. The initialized pins are now included in the program, but we need to assign another one of them an interruption handler, so that we can react to the button press in our program. // Setting interruption handler for button
gpio_set_irq_enabled_with_callback(BUTTON1, GPIO_IRQ_EDGE_RISE, true, buttonFxn);
RTOS handles the tasks and interruption handler functions in a similar way. More about interruptions later, but when the state of the button changes (on falling edge, because of the constant
GPIO_IRQ_EDGE_FALL), it causes an interruption. With the function gpio_set_irq_enabled_with_callback we set a function to be executed in response to the interruption, in other words, its handler. In this program, the function buttonFxn is the handler of this interruption.We can also see that in addition to the constant and handler function, we give other arguments to the function from
gpio-library. At first, we need to tell the library, what pin is this interruption handler associated with, here we obviously set the pin to be BUTTON1, that means the button that was initialized earlier. The second-to-last value true given to the function means that we want to enable the interruption now.Note! We could have implemented the same checking of the button state with superloop-structure so that in an infinite loop we would ask the state of the button in every iteration, and if it was changed, we would perform some action. Well, these superloop-things again, but as we can see the same goal was achieved much easier by using a handler.
Outputting analog data: PWM¶
In microcontrollers, digital pins can only output two values: 0 (low, 0 V) or 1 (high, typically 3.3 V in the Pico). But what if we want to dim an LED, drive a motor or create something that looks like an “analog” signal? For this we can use Pulse Width Modulation (PWM). PWM is a technique where the pin is rapidly switched on and off, and the ratio of on-time to the total period (the duty cycle) represents the average output value.
We said before that each GPIO pin on the Pico can be configured to use one of these hardware functions, such as UART, SPI, or PWM. In Raspberry Pi Pico we use the
gpio_set_function to that purpose. Hence, to use PWM on a pin, we first need to configure that pin’s function with gpio_set_function.Inside the RP2040, the PWM hardware is organized into slices. There are eight slices in total, and each slice contains two channels (A and B). You can think of a slice as the basic PWM engine, and the channels as its outputs. Each GPIO pin that supports PWM is connected to one of these slice channels. This means that every pin capable of PWM is already “wired” internally to a particular slice. To use PWM correctly, we need to know which slice a pin belongs to. We can find this information in the RP2040 datasheet, but in practice it is much easier to use the SDK helper function
pwm_gpio_to_slice_num, which returns the slice number for a given GPIO pin. Once the slice is identified, we enable it and set the duty cycle for our pin. If we do not explicitly configure anything else, the PWM counter runs with a default range of 0 to 65535. This is why the duty cycle values in the SDK are also given in the range 0–65535: a value of 0 means always off, 65535 means always on, and 32768 would be about 50% on-time.Let’s look at a simple example where we control the brightness of a single LED connected to GPIO pin
LED1 by controlling the duty cycle of PWM signal. // Configure GPIO pin for PWM
gpio_set_function(LED1, GPIO_FUNC_PWM);
// Find which PWM slice controls this pin
uint slice_num = pwm_gpio_to_slice_num(LED1);
// Set duty cycle (0-65535). Higher value = brighter LED.
pwm_set_gpio_level(LED1, 32768); // ~50% duty cycle
// Enable the PWM slice
pwm_set_enabled(slice_num, true);
In the previous code:
- First, we set
LED1to act as a PWM output instead of a standard GPIO. - Then, we find the PWM slice number that corresponds to this pin. Each pin belongs to one slice, and the SDK provides
pwm_gpio_to_slice_numto discover it. - After that, we set the duty cycle with
pwm_set_gpio_level. The level indicates the maximum value that keeps a signal as high. After that, and until we enter in a new cycle the signal is low. In this case, the value is 32768, which is roughly 50% of the maximum 65535. That means the LED will be on for half of the time, making it appear at half brightness. - Finally, we enable the slice with
pwm_set_enabled, which starts the PWM hardware.
To conclude¶
In addition to memory-mapped I/O, another way of doing I/O exists: port-mapped I/O, where registers are used through separate in- and out-commands. Well, Pico does not use this kind of mechanism.
But hey.. based on the material, you can already create an embedded program, that blinks the LED of the device, if it recognizes a button press from its user! Time to celebrate?