indiantinker's blog

[Zephyr on ESP32] : UART Communication

Welcome back to learning ZephyrOS. In the last tutorial I talked about basic setting up. In this one, we will work on the next cool thing i.e. UART or more colloquially, Serial Communication.

Things in the Zephyr universe are not exactly like Serial.begin(9600) and Serial.print("Hello World"). But they are not too different either. One of the key questions I had was why are peripherals managed so differently in Zephyr? I have been programming microcontrollers for years, but in Zephyr, things still feel similar but different. For example, when writing an ISR, a callback container is involved, which contains the ISR (which is a callback) 🫣. I thought it was me being a cranky neophyte, but rather it was the prudential design of the whole RTOS. It is designed to be portable. So, the code written on ESP32 can be moved to say an ARM chip and things will work after a few tweaks (to the device tree). Hence, they have to account for a lot of possibilities and find a way that works for most. This will be a central theme that keeps coming up as we wade through the peripherals.

The code for this and other tutorials are now on GitHub.

With that in mind, let's get to know how UART works on Zephyr.

Basic Example

Let's just print the infinite list of numbers on the serial terminal. I hope you can recover the terminal session from the last tutorial. If not, check the common problems section. The project code is here.

The code to print a number a second is quite palatable. We use printk to print a new number every second on a new line.

#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/uart.h>
#include <zephyr/sys/printk.h>

void main(void)
{
    int count = 1;

    while (true) {
        printk("%d\n", count);
        count++;
        k_msleep(1000);
    }
}

Remember the main commands are :

  1. Clean: rm -rf build
  2. Compile: west build -p auto -b esp32_devkitc_wroom .
  3. Flash: west flash
  4. Use terminal: screen /dev/cu.usbserial-0001 115200

Regarding screen, to exit you should Ctrl+A, then k and then y. You can also use west monitor but that somehow does not work all the time in my case.

If all goes well, you should get this output on your screen. SerialZephyr

Reading Serial Data

Now let's combine the things we learned in the previous blinky post and control the LED with a simple serial command. So, we will check for if data is available (poll) on the UART buffer and then if it is something we are interested in, we take action. Let's see how we translate this in Zephyr code.

// This is like including libraries in Arduino
#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/uart.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/sys/printk.h>

// Like #define LED_PIN 13 in Arduino
#define LED_PIN 25
// This is Zephyr's way of getting the UART device, similar to Serial in Arduino
#define UART_NODE DT_NODELABEL(uart0)

// These are like declaring global variables in Arduino
static const struct device *uart_dev = DEVICE_DT_GET(UART_NODE);
static const struct device *gpio_dev = DEVICE_DT_GET(DT_NODELABEL(gpio0));

// This is like void setup() and void loop() combined in Arduino
void main(void)
{
    int ret;
    unsigned char rx_buf[1];

    // This is similar to checking if Serial is available in Arduino
    if (!device_is_ready(uart_dev)) {
        printk("UART device not ready\n");  // Like Serial.println() in Arduino
        return;
    }

    // Checking if GPIO is ready (not typically needed in Arduino)
    if (!device_is_ready(gpio_dev)) {
        printk("GPIO device not ready\n");
        return;
    }

    // This is like pinMode(LED_PIN, OUTPUT) in Arduino
    ret = gpio_pin_configure(gpio_dev, LED_PIN, GPIO_OUTPUT_INACTIVE);
    if (ret < 0) {
        printk("Error configuring GPIO pin\n");
        return;
    }

    printk("LED Control: Send '1' to turn ON, '0' to turn OFF\n");

    // This is like void loop() in Arduino
    while (1) {
        // This is similar to if (Serial.available()) in Arduino
        if (uart_poll_in(uart_dev, rx_buf) == 0) {
            // This is like char incomingByte = Serial.read() in Arduino
            if (rx_buf[0] == '1') {
                gpio_pin_set(gpio_dev, LED_PIN, 1);
                printk("LED ON\n");
            } else if (rx_buf[0] == '0') {
                gpio_pin_set(gpio_dev, LED_PIN, 0);
                printk("LED OFF\n");
            }
        }
        k_msleep(10);
    }
}

The code looks quite similar to what we would do in an Arduino, minus the setup parts.

Key things to note:

  1. uart_poll_in() is similar to checking Serial.available() and then Serial.read() in Arduino.
  2. Zephyr requires more explicit device setup and checking. But it is still less explicit if you are coming from programming in assembly or embedded C.
  3. UART (Serial) operations are more low-level in Zephyr compared to Arduino's abstracted Serial class.

But hey! Before you go all flashy flashy, we have one step left.

We have to tell Zephyr what peripherals we will be using and how. This is done in prj.conf file. In the project, we will be using :

So, the file looks like :

CONFIG_GPIO=y
CONFIG_SERIAL=y
CONFIG_UART_INTERRUPT_DRIVEN=n

Now when you go flashy flashy, you should be able to control the LED by pressing 1 or 0 on your screen terminal. Screenshot 2024-09-15 at 12

The complete code is here.

Interrupts on Serial Read

I mean, I am pretty sure reading the serial is important but sometimes you want it to be the priority. In an Arduino, we sometimes do not have such precise control. But, we are in the RTOS game here. The stakes are high.

We might want to control the tubes on the Number Four RBMK reactor in Chornobyl and do not want our application code to ignore our commands while it waits for a button to debounce. The application code needs to get priorities straight like you in your life. So, we have interrupts.

Here is how Zephyr does it. Let's see if you can figure it out.

#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/uart.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/sys/ring_buffer.h>

#define LED_PIN 25
#define UART_NODE DT_NODELABEL(uart0)
#define RING_BUF_SIZE 64

static const struct device *uart_dev = DEVICE_DT_GET(UART_NODE);
static const struct device *gpio_dev = DEVICE_DT_GET(DT_NODELABEL(gpio0));

// Ring buffer for storing received data
RING_BUF_DECLARE(uart_ringbuf, RING_BUF_SIZE);

// Function to process received data
static void process_uart_data(uint8_t data)
{
    if (data == '1') {
        gpio_pin_set(gpio_dev, LED_PIN, 1);
        printk("LED ON\n");
    } else if (data == '0') {
        gpio_pin_set(gpio_dev, LED_PIN, 0);
        printk("LED OFF\n");
    }
}

// UART interrupt callback
static void uart_cb(const struct device *dev, void *user_data)
{
    uint8_t received_byte;
    if (!uart_irq_update(dev)) {
        return;
    }

    while (uart_irq_rx_ready(dev)) {
        uart_fifo_read(dev, &received_byte, 1);
        ring_buf_put(&uart_ringbuf, &received_byte, 1);
    }
}

void main(void)
{
    uint8_t data;
    
    if (!device_is_ready(uart_dev) || !device_is_ready(gpio_dev)) {
        printk("Devices not ready\n");
        return;
    }

    // Configure LED
    gpio_pin_configure(gpio_dev, LED_PIN, GPIO_OUTPUT_INACTIVE);

    // Configure UART
    uart_irq_callback_user_data_set(uart_dev, uart_cb, NULL);
    uart_irq_rx_enable(uart_dev);

    printk("LED Control: Send '1' to turn ON, '0' to turn OFF\n");

    while (1) {
        // Process any data in the ring buffer
        uint32_t data;
        while (ring_buf_get(&uart_ringbuf, &data, 1) > 0) {
            process_uart_data(data);
        }
        k_msleep(10);  // Small delay to prevent busy-waiting
    }
}

The main part of the code is on setting interrupts. Let's examine that for a bit.

uart_irq_callback_user_data_set(uart_dev, uart_cb, NULL);
uart_irq_rx_enable(uart_dev);

uart_irq_callback_user_data_set(uart_dev, uart_cb, NULL); This line is essentially telling the system, "When UART data arrives, here's what you should do."

  1. uart_dev: This is our UART device that we're configuring.
  2. uart_cb: This is the name of our callback function (Interrupt Service Routine) that will be called when data arrives.
  3. NULL: This is for optional user data that we're not using in this case. You can pass additional data here that you would otherwise make a global variable.

Think of this as setting up a dedicated hotline. You're saying, "If this phone (UART) rings, immediately transfer the call to this specific person (uart_cb function)."

uart_irq_rx_enable(uart_dev); This line turns on the interrupt for receiving data. It's like flipping the switch to activate our hotline. After this, the system will interrupt normal operation whenever UART data is received and call our uart_cb function.

The other parts of the code, are storing the data in the ring buffer ASAP and then checking them in the main loop. You can do that in the ISR too, depending on how important the action is.

We need to update our prj.conf file as we are using interrupts and another memory peripheral called Ring Buffer

CONFIG_GPIO=y
CONFIG_SERIAL=y
CONFIG_UART_INTERRUPT_DRIVEN=y
CONFIG_RING_BUFFER=y

When you go all flashy flashy, the code should work exactly like before.

The complete code is here.

That is all for now. Please feel free to reach out to send feedback.

Cheers, Rohit