Serial communication¶
Learning goals: Two ways to implement serial communication in an embedded device.
Serial communication means techniques used in the data transmission between devices and components, where data moves in serial format, or in other words, sequentially bit-by-bit through one line. Data can move through the line one way, which means that there needs to be separate lines for receiving and transmitting, or the same line can be used for receiving and transmitting, taking turns in a synchronized way. In the latter case, the communication is often synchronized by a clock line (synchronous communication). In the former case, when there are only two communication lines, there is usually no clock signal and the communication is asynchronous.
Below in the picture is the simple idea of serial communication. Two devices have defined from their I/O-pins transmit- (Tx-pin) and receive-lines (Rx-pin). The devices are connected to each other so that the transmit line of one device is the receive line of another. By doing this, the sequence of bits (the states of Tx-pin as a function of time) from the sender shows up in the states of the receiver's Rx-pin.
Data is transmitted through data lines from one device to another as a bit sequence. This means that both ends of the transmission must agree on the format of the message (the meaning of each bit), that is, the data transmission protocol. In addition, both ends must use the same transmission speed.
Inside of the data frame of the data transmit protocol, we transfer the actual information, but in addition to that, there might be for example information related to error correction included in the protocol.
Serial communication protocols are mostly standardized, which helps devices (from different vendors) to communicate with each other. Transmit speed on the other hand tells us what is the duration of each bit in the line. If this is not known, it is hard to know in the receiving end, how to interpret the bits from the sequence. In other words, the receiving end would not know, at what point of the signal does the bit start and end, and in which part is the bit supposed to be read. The transmission speed can also be set by the earlier-mentioned clock.
The option introduced earlier for data transmission between components (and systems) is parallel data transfer, examples of which are the address, control and data lines, where bits move at the same time through their own wires. This way, parallel data transfer is faster than serial, but its price is the need for additional parallel wires and I/O-pins. From this point of view, serial communication is more economically efficient, transmission speeds have also increased a lot nowadays.
In this course, we are not going to dive any deeper into the secrets of serial communication. Different, more or less standardized implementations exist in large numbers, but we are going to introduce (and use) the universal serial communication technique UART and I2C-protocol, which we are going to use with the sensors integrated to Pico's extension board. I2C is very common and fairly fast (clock frequency 100-400kHz) serial communication protocol for embedded systems.
Universal Asynchronous Receiver/Transmitter¶
Universal Asynchronous Receiver/Transmitter (UART), is a serial communication circuit, that converts parallel-type information into serial information, to make communication with peripheral devices easier. With this circuit, we can implement for example data transfer according to the well-known RS-232-standard. UART is general-purpose and a bit old technique, but widely used in embedded systems for ASCII/text-based, bidirectional data transfer. UART can also be used for transmitting binary format ("encoded into numbers") data. One example of this kind of data transfer is writing things to the program memory of an embedded device, when we are uploading our program to it.
A good example of a peripheral device using UART in embedded systems is a GPS-receiver, that sends coordinate information in human-readable NMEA-text format. Another example could be a smallish command line interface based on UART serial communication between a workstation PC and an embedded device. And of course information in CSV-format can be transferred by using UART.
Without going too deep into the protocol implementation, we can conclude that UART-based serial communication needs a couple of data transfer parameters:
- Transmission speed (in bauds). Common speeds include 9600, 19200, 38400, 57600 and 115200 bits/second.
- Number of data bits: in this course always 8
- Parity bit: not used in this course
- Number of Stop-bits: in this course always 1.
As a result, the parameters of serial communication are denoted as (for example) 9600 8n1. It is important of course to configure the transmitter and receiver with the same parameters, so that they can understand each other. The upside of this is that when we know the parameters, we don't need a clock line.
Pico UART¶
Next we will practice with an example, how to use the serial communication library
uart, that is also included in the Pico SDK.In the task
serialTask below:- First, we initialize the UART communication on
uart0with the parameters (9600, 8N1). - Then we configure the GPIO pins 0 and 1 to work as UART pins using
gpio_set_function. This sets up the physical TX and RX lines. - Inside the infinite loop, we wait for input with
uart_getc, which blocks until a single character is received from the serial connection. - Note that if you want to read multiple characters at the same time, you can use the
uart_read_blocking (uart0, buffer, buffer_len)where buffer is defined asuint8_t buffer[buffer_len]. - When a character arrives, we use
sprintfto format a message string (for example: "Received: A") and send it back usinguart_puts. The first parameter tells which UART to use, and the second is the string to transmit. - Note that you can also send multiple characters at the same time, you can use:
uart_write_blocking (uart0, buffer, buffer_len)where buffer is defined asuint8_t buffer[buffer_len]. - Finally, the task calls
vTaskDelayto pause for 100 ms, which yields time to other tasks before repeating the cycle.
#include <hardware/uart>
...
// Task function
void serialTask(void *pvParams) {
char input;
char echo_msg[30];
// Initializing serial communications
// Parameters 8n1 9600
uart_init(uart0, 9600);
// Setting up UART with default pins
gpio_set_function(0, GPIO_FUNC_UART);
gpio_set_function(1, GPIO_FUNC_UART);
// Eternal loop
while(1) {
// Receiving 1 character at a time to the input-variable
input = uart_getc(uart0);
// Sending back a string
sprintf(echo_msg, "Received: %c\n", input);
uart_puts(uart0, echo_msg);
// Politely sleeping for a second
vTaskDelay(100 / portTICK_PERIOD_MS);
}
}
int main(void) {
...
stdio_init_all();
...
return 0;
}
Serial communication through UART in Pico is in practice just the usage of simple read and write functions, as long as we have an open connection. When opening the connection, it is important to set the desired pins to be used with UART by using the
gpio_set_function function call.Lastly, the serial connection needs to be closed with the function
uart_deinit. In the example we haven't used it, since we are operating inside of an infinite loop.IMPORTANT: It is necessary to check which of the two available UART (UART0 or UART1) can be connected to each one of the pins. This can be found, for instance, in Pico pinout.
Serial communication interrupt¶
In previous example, we saw how we can read data from serial connection with
uart_getc-function so that the task was blocked while waiting for data to be read. However, this is not the best way to get data from UART, because, well.. the task gets blocked until data is received. Now, from the perspective of the MCU, serial communication through UART is a slow operation, so this is not a good practice.A better way to do this is to set up the serial communication to work in a non-blocking way, so that only when new data is available, an interruption occurs. This can be done by modifying the parameters of our UART connection and of course by using a handler.
// Handler function
void uartFxn() {
// Now new data is available, it is given to some other function for handling (just for example)
do_something_fast(uart_getc(uart0));
}
void uartTask(void *pvParams) {
uart_init(uart0, 9600);
gpio_set_function(0, GPIO_FUNC_UART);
gpio_set_function(1, GPIO_FUNC_UART);
// Assigning an interruption handler uartFxn for the interruptions of UART0
irq_set_exclusive_handler(UART0_IRQ, uartFxn);
// Start receiving interruptions
irq_set_enabled(UART0_IRQ, true);
// We tell the UART that we want to receive interruptions,
// when new data is available
uart_set_irq_enables(uart0, true, false);
while(1) {
//Infinite loop.
tight_loop_contents();
//Here for instance, we could check if there is new data and operate with it.
}
}
int main() {
...
stdio_init_all();
...
return 0;
}
Now in the code, the
uart library of Pico SDK is initialized inside of a task instead of the main-function. The idea here is still that we isolate the serial communication in its own task.The difference compared to the previous initialization of serial communication is that here we also set up an interrupt handler using the function
The second argument of this function is set to
irq_set_exclusive_handler, and then enable interrupt processing with the functions irq_set_enabled and uart_set_irq_enables. With these functions, we first inform the processor’s that we want to handle UART interrupts (constant UART_IRQ). After that, we configure the UART peripheral itself to generate interrupts by calling uart_set_irq_enables. The second argument of this function is set to
true, which enables interrupts when data is received. It is also possible to enable interrupts for transmission (third argument), but in this example it is not necessary. UART and USB¶
The traditional way to communicate a embedded device with a computer has been through a UART interface. Because modern computers no longer include physical serial ports, we need an extra adapter to convert UART signals (TTL levels) into USB. This is done using an FTDI adapter (also called TTL-to-Serial), which can be a simple cable or an integrated chip on a development board. For example, many Arduino boards and the TI SensorTag include such a chip without us even noticing it, so that the USB cable we connect is actually converted into a UART channel internally. When communicating with a workstation, usually speed and other parameters need to be set to its serial port (e.g /dev/ttyACM0, in Unix, COMx in Windows, etc.). Pico's drivers create one logical serial port (named Raspberry Pi Pico) from its USB-connection with the workstation. This UART over USB to communicate with the device by using a terminal program, screen or Putty for example.
In contrast, the Raspberry Pi Pico can act directly as a USB device without requiring any external adapter. It supports the USB class CDC-ACM, which means it can present itself to the host computer as a virtual COM port. This way, when we call functions like
printf in our code, the text can be sent through USB and appear as if it were transmitted over a UART. So, with Pico we can send serial data over USB directly, while still having the option to use the physical UART pins if we want a real hardware serial connection to another device. Since it is a serial communication we can also use any of the previous programs to communicate with the pico, namely screen or Putty for example.More on USB communication below.
I2C-bus¶
I2C-serial communication bus is a well-known binary-format serial communication protocol for embedded devices.
The functionality of I2C-bus is based on the master/slave-architecture, that has been used since the dawn of computer technology. Connected to a bus, there is always one master device that controls it, and n slave devices. Here master initiates the connection and sets the transmission speed, that the slave devices follow. I2C needs two I/O-pins, clock (Serial Clock Line, SCL) and data line (Serial DAta Line, SDA).
Now, in I2C bus the devices and components are identified by addresses. The address has been pre-defined by the manufacturer of the device and it can be found from the datasheet of the component. Sometimes manufacturers provide several I2C-addresses for their components and in this case, the user can choose an address. This is extremely useful especially when there are many similar components in one bus, for example sensors. One I2C-bus can have 1008 devices connected to it simultaneously! The address of an I2C device is 7 bits, followed by one bit that indicates whether the operation is a read (1) or a write (0).
In general, I2C-messages consist of two main elements: the receiver address and the message body. The receiver address is usually one byte long (7 address bits plus the r/w bit), and it specifies which device on the bus should respond. The message body can contain a command and, optionally, some data. Any transaction, initiates with the START condition. For example, with an I2C LCD module the master can write a command to set the cursor position, then write data bytes to display characters, and if the module exposes readable status/registers—read back information such as the busy/status flag or keypad state before sending the next command. One transaction, which may include multiple bytes, is terminated by a STOP condition.
In embedded systems, especially when working with digital sensors, this idea is typically refined to the concept of registers. Instead of generic commands, the master specifies the register address inside the sensor that it wants to access. Then the master either writes data to that register (for configuration) or reads data from it (to obtain a measurement).
The write transaction (modifying the value of a register) is simple: the first byte in the transaction (after the START condition) is the number which identifies the register and the following bytes (until the STOP condition) specify the data to be written into the register.
The read transaction is a bit more complicated. First, the master writes the address of the register it wants to read. Then it issues a repeated START (within the same transaction) and begins a read operation, which includes sending the slave address again with the read bit set.
Below is an example of the frame structure of an i2c-message. For general information only.
I2C in Pico¶
Next we will look at the usage of
i2c-library provided by the Pico SDK with the help of a code example. In the example we will read a temperature value from the temperature sensor HDC2021, which is integrated to Pico's extension board. We can see that the datasheet of the sensor is full of many types of complex information, which we don't need to familiarize ourselves with during this course. However, we are interested in the usage of the registers of the sensor (chapter 7.6 in the datasheet) through i2c. But now we are doing it based on predefined constants.void sensorTask(void *pvParameters) {
float temperature;
// Initializing I2C-bus
i2c_init(i2c_default, 400*1000);
// Setting the default pins for I2C to be used by our program
gpio_set_function(PICO_DEFAULT_I2C_SCL_PIN, GPIO_FUNC_I2C);
gpio_set_function(PICO_DEFAULT_I2C_SDA_PIN, GPIO_FUNC_I2C);
gpio_pull_up(PICO_DEFAULT_I2C_SCL_PIN);
gpio_pull_up(PICO_DEFAULT_I2C_SDA_PIN);
// Read and write buffers for I2C messages
uint8_t txBuffer[1]; // Now we send one byte
uint8_t rxBuffer[2]; // Now we receive two bytes
txBuffer [0] = HDC2021_TEMP_LOW;
while(1) {
if(i2c_write_blocking(i2c_default, HDC2021_I2C_ADDRESS , txBuffer, 1, true) != PICO_ERROR_GENERIC) {
if(i2c_read_blocking(i2c_default, HDC2021_I2C_ADDRESS, rxBuffer, 2, false) != PICO_ERROR_GENERIC) {
// Changing the 2-byte data in rxBuffer
// into a temperature value (formula in exercise material)
//PART OF THE LAB SESSION.
temperature = ... ;
// Temperature value to console window
printf("%f", temperature);
}
}
else {
printf("I2C Bus fault\n");
}
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
// Closing the I2C connection, though the infinite loop never gets here
i2c_deinit(i2c_default);
}
int main() {
...
stdio_init_all();
//Code to create the sensorTask. Seen in previous slides
//Starting the schedule.
vTaskStartScheduler();
return 0;
}
Let's look at this example step by step.
Initializations¶
Similarly to other peripheral components, Pico SDK offers us a bunch of functions and the constants needed to initialize and use them. It should be noted that in the example, everything related to i2c is done inside of the
sensorTask. The idea here is that this task handles all the i2c-traffic between the device and sensors.Now, i2c is enabled by using the initialization function provided by Pico's
i2c library. We need to tell this function, which one of the two i2c buses of our Pico we want to use. In this case we choose the default bus i2c_default. The initialization function also wants to know the data transfer speed, now we set this to 400 kHz. i2c_init(i2c_default, 400*1000);
Just like in the case of UART, we need to reserve some pins to be used with i2c. Here we are going to use the default pins for i2c in Pico, and set the function of those pins to be I2C. Additionally, we also want to set the pins to a known state, which in this case is
high. This is done with the function gpio_pull_up. Actually, the bus should have pull up resistors, but we will use the pull-up of the pins just in case. gpio_set_function(PICO_DEFAULT_I2C_SCL_PIN, GPIO_FUNC_I2C);
gpio_set_function(PICO_DEFAULT_I2C_SDA_PIN, GPIO_FUNC_I2C);
gpio_pull_up(PICO_DEFAULT_I2C_SCL_PIN);
gpio_pull_up(PICO_DEFAULT_I2C_SDA_PIN);
Communication using the i2c-bus¶
In the example we request for measurement values from the temperature sensor HDC2021. To do this, we need to define storage areas for the resulting data, which are also called buffers. Here we need to define two buffers:
- Transmit buffer
txBuffer, the size of which is defined in the datasheet (well, in our course material also). Typically, when performing a read operation we only need to send the register address we want to read. - In the example we set the address of the data register of HDC2021 to the transmit buffer
txBuffer [0] = HDC2021_TEMP_LOW;
by using the constants provided by a library.
- Receive buffer
rxBuffer, where the received i2c message is stored. The size of this buffer is also defined in the datasheet. - From the course material we know that the HDC2021 returns temperature value as a 16-bit value, so the size of the receive buffer is two bytes.
uint8_t txBuffer[1];
uint8_t rxBuffer[2];
txBuffer[0] = HDC2021_TEMP_LOW;
Note! It is also possible to send i2c messages that do not contain any data, like some commands. In this case, we obviously don't need a buffer for data reading.
When the buffers are defined and the register to read is assigned to the transmit buffer, we can send the message to the peripheral device. Notice that the transaction is not ended here, because the parameter 'noStop' is set to
If an error occurs,
true in i2c_write_blocking.If an error occurs,
PICO_ERROR_GENERIC is returned from the function call i2c_write_blocking. If this happens, we know that an error has occured and we should not try to read value from the sensor. if(i2c_write_blocking(i2c_default, HDC2021_I2C_ADDRESS, txBuffer, 1, true) != PICO_ERROR_GENERIC) {
...
}
else {
printf("I2C Bus fault\n");
}
If however, there is no error, we can read the value from the sensor:
...
if(i2c_read_blocking(i2c_default, HDC2021_I2C_ADDRESS, rxBuffer, 2, false) != PICO_ERROR_GENERIC) {
temperature = ... ;
printf("%f", temperature);
}
else {
printf("I2C Bus fault\n");
}
...
Here we also need to consider the possible error and inform about it by using the console window.
If reading the messages succeeds, the 16-bit value from the data register of the sensor is now stored to the two bytes in
rxBuffer. The task of the programmer is now to convert the value of rxBuffer into a temperature value. The formula for this is presented in the exercise tasks and also in the later course material.Closing the i2c-connection¶
There might be a need to close the i2c-connection in a program. It can be done in a following way:
...
i2c_deinit(i2c_default);
...
USB communication¶
USB in RP2040¶
The RP2040 has an integrated USB controller, so we can use USB directly without any external chips. This controller can work in two modes: as a USB host (controlling other USB devices such as a keyboard or flash drive) or as a USB device (appearing to a computer as a peripheral). In our case, we are interested in the device mode, where the Pico supports the CDC-ACM profile (Communications Device Class – Abstract Control Model).
This profile allows us to use the Pico as a virtual serial port, so that serial communication (normally UART TTL signals) can be carried over USB. This is extremely practical because it lets us communicate directly with a computer or workstation using tools like minicom, screen, or any other serial terminal.
This profile allows us to use the Pico as a virtual serial port, so that serial communication (normally UART TTL signals) can be carried over USB. This is extremely practical because it lets us communicate directly with a computer or workstation using tools like minicom, screen, or any other serial terminal.
Under the hood, the Pico SDK relies on the TinyUSB library to provide the USB functionality.
In the following examples we will see how to send data from the Raspberry Pi Pico to the computer via USB.
To use TinyUSB in your own project, two files are essential:
In the following examples we will see how to send data from the Raspberry Pi Pico to the computer via USB.
To use TinyUSB in your own project, two files are essential:
tusb_config.h: the configuration header where you define which USB classes are enabled (for example CDC, HID, MSC), buffer sizes, and other compile-time options.usb_descriptors.c: the source file that defines the descriptors telling the host how the Pico should appear (for example, as a CDC-ACM virtual serial device), including vendor/product IDs and string descriptors.
You do not need to worry about those files, in the example configuration we have included those files for you.
stdin and stdout over USB¶
Another option, often the simplest in practice, is to redirect the C standard streams stdin, stdout and stderr to the Pico’s USB CDC-ACM interface. When configured this way, the board also appears to the host as a serial port; you can open it with a terminal such as minicom] or PuTTY], and then send and receive text or binary data through the standard I/O functions.
To make USB work as your stdio backend, you need to initialize it in code and enable it in the build. In code you can call
stdio_init_all() to bring up all enabled backends, or stdio_usb_init() if you want to initialize only the USB backend yourself. In the build system you must link the USB stdio support and enable it for your target. With the Pico SDK this is typically expressed in CMake as:pico_enable_stdio_usb(your_target 1) pico_enable_stdio_uart(your_target 0) // optional
This pulls in the required
pico_stdio_usb support and routes the C streams to USB for that executable. Disabling UART is optional, but it avoids confusion about where your prints go.Once USB stdio is active, you can use the usual C I/O along with a couple of Pico-specific helpers. For single characters to
stdout, putchar() writes one character and, on the USB backend, a newline \n is often translated to \r\n which is convenient for many terminals. If you need to send bytes exactly as they are (e.g. for sending binary data), use the Pico helper putchar_raw(), which performs no line-ending translation. For printing whole lines of text, puts() writes a C string and appends a newline automatically. Remember that the same newline translation applies on USB when not using the raw path.For higher throughput, prefer block writes. The standard call
fwrite( char *ptr, int size, int count, stdout) sends a block of bytes from memory to the stream and is ideal when you have buffered data such as sensor frames or audio samples. After a large fwrite(), you may want to flush the buffer so it leaves the device promptly; you can use fflush(stdout) from the C library or the Pico helper stdio_flush() to nudge pending data out over USB.Reading is symmetrical. The standard
getchar() performs a blocking read of one character from stdin. In this case, your code will wait until something arrives over the USB serial connection. If you prefer not to block, the Pico SDK provides getchar_timeout_us(timeout_us). With a timeout of zero it becomes a non-blocking poll: if a byte is available, you get it as a non-negative return value; if not, you get PICO_ERROR_TIMEOUT. This is a good fit for main loops that must keep running other tasks while occasionally checking for inbound data. If you need to read many bytes at once, standard buffered I/O such as fread() on stdin works well and pairs naturally with fwrite() for echoing or forwarding.Here is a compact baseline that brings the pieces together and follows the approach above:
#include "stdio.h"
#include "pico/stdlib.h"
#include "pico/stdio.h"
int main(void) {
stdio_init_all(); // or stdio_usb_init();
while (!stdio_usb_connected()) { // optional: wait for a terminal
sleep_ms(10);
}
puts("USB ready.");
while(1) {
int ch = getchar_timeout_us(1000); // non-blocking poll
if (ch >= 0) {
putchar_raw((char)ch); // echo back exactly what we received
}
// Example block write elsewhere:
// fwrite(buf, 1, len, stdout);
// stdio_flush();
tight_loop_contents();
}
}
With this setup you have clear choices:
putchar() and puts() are convenient for human-readable logs, putchar_raw() and fwrite() give you exact-bytes control and efficient block transfers, and getchar()/getchar_timeout_us() cover blocking and non-blocking input respectively. Separate debugging and serial port using USB¶
In order to make your life easier, we have created a library that generates two different serial ports. One is for sending serial data using the TinyUSB library, and the other is for sending debug information. You will find this library in the project provided for the exercises.
In this case, sending debug information requires avoiding the standard stdio functions (
printf, puts, …) and instead using a customized library that we have prepared for the course. Furthermore, the library integrates with FreeRTOS, so you do not need to worry about integration issues. You also do not need to write the descriptors.c or tusb_config, since they are included in the library. You only need to link the library in your CMake and disable pico_enable_stdio_usb(your_app 0), because we are not using the standard stdio.On your computer, you need to create a serial terminal (e.g., using Arduino, minicom, screen, PuTTY, …). The lower port (
/dev/ttyACM0) in Unix behaves like printf, while the upper port (/dev/ttyACM1) is used to send and receive data through the TinyUSB interface.Let’s start with an example that sends sensor data from the Pico to your computer. The data is processed in one task and sent to the computer’s terminal:
#include "pico/stdio.h"
#include "FreeRTOS.h"
#include "task.h"
#include "tusb.h"
#define CDC_ITF_TX 1
// ---- Task generating sensor data ----
static void sensorTask (void *arg){
char buf[BUFFER_SIZE];
while (!tud_mounted() || !tud_cdc_n_connected(1)){
vTaskDelay(pdMS_TO_TICKS(50));
}
while (1) {
int temp = ....; // Obtained somewhere else in the code
int lux = ....; // Obtained somewhere else in the code.
if (tud_cdc_n_connected(CDC_ITF_TX)){
// Send them using tud_cdc_n_write
snprintf(buf, BUFFER_SIZE, "%d, %d\n", temp, lux);
tud_cdc_n_write(CDC_ITF_TX, buf, strlen(buf));
tud_cdc_n_write_flush(CDC_ITF_TX);
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
// ---- Task running USB stack ----
static void usbTask(void *arg) {
(void)arg;
while (1) {
tud_task(); // With FreeRTOS, wait for events
// Do not add vTaskDelay.
}
}
int main (void) {
// Create tasks
TaskHandle_t hUsb = NULL;
xTaskCreate(usbTask, "usb", 1024, NULL, 3, &hUsb);
xTaskCreate(sensorTask, "app", 1024, NULL, 2, NULL);
#if (configNUMBER_OF_CORES > 1)
vTaskCoreAffinitySet(hUsb, 1u << 0);
#endif
// Initialize TinyUSB first. This code should be just before to vTaskStartScheduler();
tusb_init();
vTaskStartScheduler();
}
Let’s start by checking the libraries:
#include "pico/stdio.h"
#include "FreeRTOS.h"
#include "task.h"
#include "tusb.h"
The novelty here is adding <tusb.h>, which is the library used to create a serial port over USB.
Our
main function generates two different tasks. One is the task in charge of handling the whole USB stack (with the intuitive name usbTask). Note that this task has higher priority than the rest (in this case, 3):xTaskCreate(usbTask, "usb", 1024, NULL, 3, &hUsb);Its body is very simple: it just calls
tud_task, which waits for events coming from the USB stack. Please note that you do not need to include vTaskDelay, since tud_task() blocks until an event happens.The
sensorTask simply sends the collected values of temperature and luminance to the computer. First, it checks if we can send data through the CDC port (identified as 1 in this case) using tud_cdc_n_connected. If so, it writes the data to a buffer using snprintf (we told you this function would be useful). Finally, the data is sent using tud_cdc_n_write(1, buf, strlen(buf)), and we ensure that it has left the Pico with tud_cdc_n_write_flush(1).Ok, but what about sending data from the computer to the Pico? That’s easy: use your serial terminal to type data. How is the Pico able to read it? It uses a callback function — specifically,
void tud_cdc_rx_cb(uint8_t itf);. You can see the full callback API in the sources. Let’s see how it’s implemented.// callback when data is received on a CDC interface
void tud_cdc_rx_cb(uint8_t itf){
// allocate buffer for the data on the stack
uint8_t buf[CFG_TUD_CDC_RX_BUFSIZE + 1];
// read the available data
// | IMPORTANT: do this for CDC0 as well, otherwise
// | printing to CDC0 can stall (its RX buffer fills up)
// | before the next time this function is called
uint32_t count = tud_cdc_n_read(itf, buf, sizeof(buf));
// check if the data was received on the second CDC interface
if (itf == 1) {
// Process the data here (e.g., store in a buffer or send to another task).
// ...
// Be gentle and send an OK back.
tud_cdc_n_write(itf, (uint8_t const *) "OK\n", 3);
tud_cdc_n_write_flush(itf);
}
// Optional: if you need a C-string, you can terminate it:
// if (count < sizeof(buf)) buf[count] = '\0';
}
The new piece here is the read call:
It reads data from USB into
uint32_t count = tud_cdc_n_read(itf, buf, sizeof(buf));It reads data from USB into
buf. You’ll then process that data as needed. In this example we just send a friendly acknowledgment back.Please note:
- You should perform the read on both interfaces (including the one used for debug). Even if you don’t use that data, reading prevents the RX buffer from filling and blocking prints.
- The function name must be exactly
- This callback is not an interrupt handler you write yourself (interrupts are handled inside the stack). Still, keep work inside the callback light: avoid heavy processing and large prints here. Offload substantial work to tasks.
- You should perform the read on both interfaces (including the one used for debug). Even if you don’t use that data, reading prevents the RX buffer from filling and blocking prints.
- The function name must be exactly
tud_cdc_rx_cb. Do not rename it; otherwise the TinyUSB stack won’t know which function to call when data arrives.- This callback is not an interrupt handler you write yourself (interrupts are handled inside the stack). Still, keep work inside the callback light: avoid heavy processing and large prints here. Offload substantial work to tasks.
Finally, we need to show how to use Port 0 (CDC0) to send debug data. Let’s slightly modify the initial example.
#include "pico/stdio.h"
#include "FreeRTOS.h"
#include "task.h"
#include "tusb.h"
#include "usbSerialDebug/helper.h"
#define CDC_ITF_TX 1
// #define BUFFER_SIZE ... // define this to a suitable size if not defined elsewhere
// ---- Task generating sensor data ----
static void sensorTask(void *arg) {
char buf[BUFFER_SIZE];
// Wait until USB is mounted and CDC1 is connected (data port)
while (!tud_mounted() || !tud_cdc_n_connected(CDC_ITF_TX)) {
vTaskDelay(pdMS_TO_TICKS(50));
}
while (1) {
int temp = ....; // Obtained somewhere else in the code
int lux = ....; // Obtained somewhere else in the code
// Send sensor data over CDC1 using TinyUSB (computer reads /dev/ttyACM1 on Unix)
if (tud_cdc_n_connected(CDC_ITF_TX)) {
snprintf(buf, BUFFER_SIZE, "%d, %d\n", temp, lux);
tud_cdc_n_write(CDC_ITF_TX, buf, strlen(buf));
tud_cdc_n_write_flush(CDC_ITF_TX);
}
// Send debug text over CDC0 using the helper library (computer reads /dev/ttyACM0 on Unix)
if (usb_serial_connected()) {
snprintf(buf, BUFFER_SIZE, "temp:%d, light:%d\n", temp, lux);
usb_serial_print(buf);
usb_serial_flush();
}
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
// ---- Task running USB stack ----
static void usbTask(void *arg) {
(void)arg;
while (1) {
tud_task(); // With FreeRTOS, wait for events
// Do not add vTaskDelay.
}
}
int main(void) {
// Create tasks
TaskHandle_t hUsb = NULL;
xTaskCreate(usbTask, "usb", 1024, NULL, 3, &hUsb);
xTaskCreate(sensorTask, "app", 1024, NULL, 2, NULL);
#if (configNUMBER_OF_CORES > 1)
vTaskCoreAffinitySet(hUsb, 1u << 0);
#endif
// This two functions should be just before vTaskStartScheduler
// Initialize TinyUSB first
tusb_init();
// Initialize helper library to write via CDC0
usb_serial_init();
vTaskStartScheduler();
}
In this case, we first include our helper library with
#include "usbSerialDebug/helper.h" and initialize it in main after TinyUSB with usb_serial_init();.Then, if you want to write debug messages in any task, use
usb_serial_print(const char *s); which takes a C string as input (it must end with \0). If you need formatting, prefer snprintf to build the buffer safely. You can also ensure the debug message leaves the Pico with usb_serial_flush(), and you can check if someone is listening with usb_serial_connected().Easy, isn’t it?
To conclude¶
Serial communication in the world of embedded systems is surprisingly complicated, with several protocols and implementations, but with the information provided here, we can get started with reading and writing data from/to the sensors included in the extension board of Pico.
In addition to i2c-protocol, SPI is another well known serial communication protocol in the world of embedded systems. The choice of protocol is of course dependent on the decisions taken by the component manufacturer.
In the examples above, we did not consider adjusting the settings of the sensor or calibration, because we know that the SDK of Pico's extension board will handle that for us. This functionality can be of course modified with own code, as long as one knows what they are doing with registers and bitwise operations.