So far in this series we have looked at stacks and queues inside the Linux kernel. This time we move to a much smaller computer: a microcontroller. And we look at Zephyr, a real-time operating system (RTOS) that runs on boards like the ESP32-S3.
If you have only written Arduino sketches so far, this post is for you. We will end with a small program where two threads talk to each other.
What is an RTOS?
Most people start embedded work with an Arduino style program. There is a setup() that runs once and a loop() that runs again and again. Inside the loop you read a sensor, blink an LED, check a button, and print something. One thing after another, forever.
That works fine for small jobs. The trouble starts when the jobs grow. Say one task needs to read a sensor every 10 ms, another has to send data over Wi-Fi, and a third is waiting for a button press. If the Wi-Fi part takes 200 ms, the sensor reads are late. The loop fills up with timing checks and flags.
An RTOS takes a different approach. Think of an office with one desk and several workers. Only one worker can sit at the desk at a time. A manager keeps a timetable and decides who sits next. When a worker is waiting for a phone call, they get up and let someone else use the desk. When an urgent job comes in, the manager lets that worker sit down first.
The desk is the CPU. The workers are threads. The manager is the scheduler.
"Real-time" does not mean fast. It means the timing is predictable. A high priority thread will run when it is supposed to, not "whenever the loop gets around to it".
What is Zephyr?
Zephyr is an open source RTOS run by the Linux Foundation. It supports hundreds of boards from many chip makers, including the ESP32 family, STM32, Nordic and others.
Here is why I think people pick it:
- It is free and open source, with a big community behind it
- Your code is not tied to one chip maker
- Hardware is described in Devicetree files, so moving to another board is easier
- It comes with drivers, logging, a shell, and networking already built in
- One tool,
west, gets the code, builds and flashes
The cost is a steeper start than Arduino. Once the ideas click, bigger projects get much easier to manage.
The building blocks
Zephyr gives you a small set of kernel objects. These are the ones you will use most.
Threads. A thread is a function with its own stack and a priority.
Sleep. When a thread calls k_msleep(100), it gives up the CPU for 100 ms. This is the big change from Arduino's delay(), which just blocks everything.
Semaphores. A semaphore is a counter used for signals. One thread gives it, another thread waits on it. A common use is an interrupt saying "data is ready" to a thread that is sleeping until then.
Mutexes. A mutex is a lock. If two threads share a buffer or a UART, a mutex makes sure only one of them uses it at a time.
Message queues. A message queue (k_msgq) passes fixed size messages from one thread to another. It is the same FIFO idea from the last post, but safe to use between threads.
Timers. A timer calls a function after a delay, once or again and again.
Workqueues. A workqueue lets you hand a job to a background thread. This is useful when an interrupt needs some slow work done but should not do it itself.
A small example
Here is a program with two threads. The producer makes a number every 500 ms and puts it in a message queue. The consumer waits for numbers and prints them.
#include <zephyr/kernel.h>
#include <zephyr/sys/printk.h>
/* a queue that holds up to 10 ints */
K_MSGQ_DEFINE(num_q, sizeof(int), 10, 4);
void producer(void *p1, void *p2, void *p3)
{
int n = 0;
while (1) {
if (k_msgq_put(&num_q, &n, K_NO_WAIT) != 0) {
printk("queue full, dropped %d\n", n);
}
n++;
k_msleep(500);
}
}
void consumer(void *p1, void *p2, void *p3)
{
int value;
while (1) {
/* sleep here until a number arrives */
k_msgq_get(&num_q, &value, K_FOREVER);
printk("got %d\n", value);
}
}
/* name, stack size, entry, 3 args, priority, options, start delay */
K_THREAD_DEFINE(prod_id, 1024, producer, NULL, NULL, NULL, 7, 0, 0);
K_THREAD_DEFINE(cons_id, 1024, consumer, NULL, NULL, NULL, 7, 0, 0);
A few things to notice. There is no main() loop doing the work. K_THREAD_DEFINE creates each thread when the system starts. K_MSGQ_DEFINE sets up the queue with room for 10 messages, each the size of an int.
The producer uses K_NO_WAIT, so if the queue is ever full it does not get stuck. It just prints a note and moves on. The consumer uses K_FOREVER, so it sleeps until there is something to read. While it sleeps, it uses no CPU at all.
Neither thread needs to know when the other runs. That is the whole point of an RTOS.
Devicetree in a few words
On Arduino you often write something like pinMode(5, OUTPUT) right in your code. If you move to another board where the LED is on pin 12, you have to change the code.
Zephyr puts hardware details in a separate file called a Devicetree. It says things like "this board has an LED on this pin" or "there is a sensor on this I2C bus at this address". Your C code asks for "the LED" by name, and the build fills in the real pin. To change the wiring, you change a small overlay file, not your program. A second file type, Kconfig, turns features like logging or the shell on and off.
What you can build
Once you know these pieces, the ESP32-S3 can do quite a lot under Zephyr. For example:
- Blinking LEDs and dimming them with PWM
- Reading a sensor over I2C
- Saving a value, like a boot count, in flash with NVS so it survives a reset
- A command shell over UART where you type commands to your board
- Driving a stepper motor through an A4988 or TMC2209 driver
- Joining a Wi-Fi network and downloading a firmware update over the air (OTA)
Try it yourself
- Change the producer to sleep for 100 ms and the consumer to sleep 1 second after each print. Watch the "queue full" message show up, and think about why.
- Add a second producer thread that sends negative numbers. Does the consumer still get every message in order of arrival?
- Replace the int with a small struct holding a number and a timestamp from
k_uptime_get(), and print both in the consumer.
Where to go from here
If you want to go further, we have a 24 week course, Zephyr RTOS on ESP32-S3. It starts with setting up Zephyr 4.1 and west on the ESP32-S3-DevKitC-1, works through threads, semaphores, message queues, timers and workqueues, and then moves on to Devicetree, GPIO, I2C, the shell, motor control, Wi-Fi and OTA, ending in a capstone project.
One thing to know first: Zephyr code is plain C, with lots of pointers and structs. Strong C basics come first. If you have done some embedded work before, that helps too, but it is not a must.

Top comments (0)