Just interested in code? Head to the repo! 🔗
If you're into hardware development you might have the following issue:
- You think of a design with different hardware peripherals and a controller
- You want to build a test setup on the breadboard
- Turns out, half the hardware you need is missing
Now you have wait for a package to arrive before you can even write the software because the in- and output devices are missing!
However, if you had a tiny, bare metal terminal for your controller, just like you see on Linux, Windows and Mac, you could test all sorts of input and output features in text format, making you able to focus on software development while your hardware is being shipped. Instead of pressing a button, you can then open a terminal and write "BUTTON" to achieve the same effect. This post will show you how you can get a mini CLI running on an ESP32, although other boards with digital interrupts on the UART RX pin will work as well. Be sure that your project contains the FreeRTOS library! The ESP32 has that included by default. If you want to know how to get other Arduino libraries up and running with the PlatformIO IDE, check out my video on PlatformIO for beginners.
Working with UART (Serial) and Strings
Everybody knows the Arduino Serial. This is a straight forward abstracted wrapper for the underlying UART peripheral: Universal Asynchronous Receiver-Transmitter. This serial bus does not have a clock line, that's why it's called "asynchronous". For this reason, both sender and receiver have to agree on a speed of transmission, this is called the baudrate. "Baudrate" can be interpreted as "symbols per second". Since we use only two symbols on the UART line, 0 and 1, the term "baudrate" might be misinterpreted as "bitrate", which is only true if the communication line has only two agreed upon levels, meaning 0V for 0 and Vcc for 1.
You might also know that this line is used with strings. Additionally you might now that the Arduino string class is a memory leaking nightmare and that actual pure-C strings do not exist, only character pointers to an array. In this video, I will show you how you can implement a command line interface (CLI) in pure C (except for the Serial-variable itself), as it is faster, safer and more universal if handled correctly. You should be familiar with strcmp() and strtok() to follow along well.
Setting up an interrupt and why
We want a 2-way communication. We send input from the terminal, the controller interprets it and sends us back a message of acknowledgement or a computed value. This means that the trigger event is always based on us humans, so the controller can't possibly know WHEN we give it new information. You can solve this problem by constantly checking for input (very inefficient btw) or you could hook it up to an interrupt which gets triggered when the UART line gets pulled low, meaning that messages are coming in. This means if we don't need anything from the controller, it's actually not doing anything, which saves power. We achieve this by attaching an interrupt to the pin connected to the UART RX line which then notifies a task to take a look at the serial line. This is a hack btw.: By triggering an interrupt at every falling edge, we run the ISR multiple times per byte, which is not good. For this reason, we need to detach the interrupt while we process our data.
Setup code
// cli.cpp
#include "string.h"
#include "cli.h"
TaskHandle_t h_cliTask = NULL; // Task handle for the CLI task (FreeRTOS)
// init all UART hardware and interrupts on the UART line, start the handling task
void initCLI(uint32_t baudrate){
Serial.begin(baudrate);
Serial.setTimeout(10);
attachInterrupt(digitalPinToInterrupt(RX_PIN), serialISR, FALLING);
xTaskCreate(processInput, "CLI event task", 4096, NULL, 1, &h_cliTask);
}
We init our CLI by starting the Serial and setting a short timeout. Then we attach an interrupt to the receiving pin which detects incoming messages. Again: if your controller does not support user-based digital interrupts, this project will not work, as our trigger mechanism will break. Next, we create the actual processing task where the magic will happen. We pass the process function that we will define in a minute, a description, memory size, no additional arguments, a priority of 1 which means very important and the yet empty task handle created above.
// cli.cpp
// interrupt service routine for incoming messages
void serialISR(void) {
detachInterrupt(digitalPinToInterrupt(RX_PIN));
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
vTaskNotifyGiveFromISR(h_cliTask,
&xHigherPriorityTaskWoken);
}
Next we define the ISR that we attached already. In it we have to detach the interrupt as explained before, or else this routine will get spammed like crazy. We then create a flag that FreeRTOS needs, this is just syntax stuff. Then we notify the actual task from the ISR, as indicated by the task handle.
Interpreting commands
Before we can assign specific commands, think of 2 important features that we always need, regardless of implementation:
- We have to make sure that the receiving buffers can't overflow, or else our controller behaves weird or crashes
- We have to print a default message in case an input is unknown
// cli.cpp
// processing task (waits for input)
void processInput(void* param) {
char inputString[CLI_BUF_SIZE] = {0}; // buffer for incoming messages
uint32_t inputIndex = 0; // current index pointing to a letter in the message
bool inputComplete = false; // flag for reaching the end of the message
printBootMessage();
printCLIHead();
while(true){
ulTaskNotifyTake(pdTRUE, portMAX_DELAY); // wait for notification from interrupt
vTaskDelay(10 / portTICK_PERIOD_MS); // wait for all messages to arrive
while (Serial.available()) { // read letters
char c = Serial.read();
if(c == '\r' || c == '\n'){ // exit on carriage return or newline
inputString[inputIndex] = '\0'; // terminate string
inputIndex = 0; // reset index
inputComplete = true; // set flag
}
else if(inputIndex < CLI_BUF_SIZE - 1){ // append data to buffer if it still has space left
inputString[inputIndex++] = c;
}
}
if(inputComplete){
parseInput(inputString);
// reset flag
inputComplete = false;
printCLIHead();
attachInterrupt(digitalPinToInterrupt(RX_PIN), serialISR, FALLING);
}
}
}
In the processing task we first define our message buffer, index pointer and a complete-flag and then a little boot up message that welcomes us to the terminal. After that we wait for the notification specified in the ISR. This is efficient because while the task taking this notification is waiting, the task scheduler suspends it and doens't waste any resources on it. If messages arrive, we can read them into the buffer byte by byte and terminate on the condition that we find a carriage return or newline. Now we can start comparing inputs.
// cli.cpp
void parseInput(char* input_string){
char* command = strtok(input_string, " ");
if (command != NULL) {
// help command
if(strcmp(command, "help") == 0){
Serial.println("commands available:");
Serial.println("\t<button>\t\ttoggle the virtual button");
Serial.println("\t<switch> <on> <off>\tturn virtual switch on or off");
}
// button command
else if(strcmp(command, "button") == 0){
Serial.println("BUTTON service routine");
// toggle a GPIO or whatever...
}
// switch command
else if(strcmp(command, "switch") == 0){
char* arg = strtok(NULL, " ");
if(arg != NULL){
// switch on command
if(strcmp(arg, "on") == 0){
Serial.println("SWITCH ON service routine");
// toggle a GPIO or whatever...
}
// switch off command
else if(strcmp(arg, "off") == 0){
Serial.println("SWITCH OFF service routine");
// toggle a GPIO or whatever...
}
// warn user about not specifying an argument
else{
Serial.printf("Unknown argument <%s>. Use <on> or <off>\r\n.", arg);
}
}
// warn user about an unknown argument
else{
Serial.println("Missing argument. Use <on> or <off>.");
}
}
// warn user about unknown command
else{
Serial.printf("Unknown command <%s>\r\n", command);
}
}
}
Using strcmp() we check what contents are inside a command and act accordingly. If a command has an additional parameter or flag seperated by a white space, we can obtain that by calling strtoken(). Before i have never seen such syntax, but the more i think about it, the more sense it makes. Call it until it returns NULL, which means that no more additional arguments are left. When you exit the routine, be sure to reset the complete-flag and the interrupt and to print your CLI header again. Now you can run all sorts of routines depending on input. For example, you could toggle an LED with the command button because you currently don't have a hardware button lying around.
// main.cpp
#include <Arduino.h>
#include "cli.h"
void setup() {
initCLI(115200);
}
void loop() {
}
Using PuTTY to interface the controller
You can use any terminal of your choice. In this video i will show you how to do it with the dedicated software "PuTTY", which in my opinion is a must-have for any developer because it can do all sorts of other connections like telnet or SSH. It's worth getting it. You need to configure some parameters to enable writing and sending per enter-key. Set your connection type to "serial" and enter your port (different for different operating systems) and baudrate as specified in the code. Give your settings a name like "Arduino_miniCLI" and head to "Terminal". There you check "force on" for both "Local echo" and "Local line editing". Go back to "Session", click your settings name and hit save, then load. A terminal should now open up.
Now you can create all sorts of quick and easy CLIs for embedded devices. You can later remove them, keep them in parallel or give them more options than the hardware interface to configure stuff behind the scenes, a sort of "developer-access". If you found this information useful, consider sharing it or even tipping me over at KoFi, all these LEDs are cutting into my budget. As always, the code from this post can be found as repo.




