This article describes library for Raspberry Pi Pico which allows controlling hobby servo motors and also read signal from radio-control receivers in C. It is based on the Pico C SDK functions.
Introduction
Raspberry Pi Pico is a microcontroller board. With its dual-core 32-bit ARM processor, 264 kB of RAM, 2 MB of flash and price around $5, it is an attractive alternative for Arduino boards for hobby projects. You can program it in MicroPython or in C using Pico SDK, or even with the Arduino framework. This article is about my library for working with hobby servomotors and radio-control receivers written in C with the Pico C SDK.
This project started when I wanted to port my older robot controlled by an RC car controller from Arduino to the Raspberry Pi Pico. I needed to measure the pulses coming from the RC receiver and control the servos which make the robot move.
Naturally, I first looked for some ready-to-use solution. There are many examples in MicroPython, but not so many in C/C++. When it comes to servos, it is pretty easy to use the PWM functions from the Pico SDK, but I wanted something more user-friendly anyway. As for working with RC receivers, it is not that easy. I found a solution based on the Pico SDK example for measuring duty cycle (which is what you actually do when processing the input from RC receiver), but it was not good (see the Points of Interest section for more information on this). So I decided to create a library that would cover both controlling the servos and processing RC receiver input.
Using the Code
For information on how the library works, please see the Points of Interest section below.
I assume you are already familiar with programming the Pico using Pico SDK. If not, please check out the excellent documentation provided by Raspberry Pi.
If you are on Windows, you probably used the Pico Windows installer from here to set up your development environment with VS Code.
To use the library, download the attached file and unzip it into some folder on your computer (or clone the repo from Github.
You can then open an example project (see the next section) or add the library to your own project as follows:
In the CMakeLists.txt file for your project, add path to the library:
# set the location of the pico_rc library
add_subdirectory("../../../pico_rc" pico_rc)
In the above example, I use relative path to the pico_rc folder, which in my case is located three levels up from the project folder. You will need to adjust the path as needed to point to the pico_rc folder where you unzipped it.
Next, add the library pico_rc
to the target_link_libraries
section of CMakeLists.txt as you would add any other Pico SDK library. Here is an example:
target_link_libraries(receiver
pico_stdlib
pico_rc
)
Now you are ready to use the library in your code. Just include rc.h in your source:
#include "pico/rc.h"
Opening Example Projects
The library contains three example projects in the examples folder:
Control
- shows how to read input from RC receiver and control some servos Receiver
- read RC received input and print is Servo
- move the servo to and fro
In this section, I try to provide step-by-step instructions for opening an example project. The instructions may be different for different operating systems and they may also be out-of-date by the time you read this. Please use your own experience with developing programs for Pico and modify the steps as needed.
To open any of the examples in Visual Studio Code (configured for Pico development):
- Select Open Folder in VS code.
- Select the folder with the example, such as servo or control. Do not select the whole examples folder.
- VS Code will open the folder and prompt you for Kit selection. You should select Pico ARM GCC... kit (on Windows).
- Wait for the project configuration and build to finish. There should be no errors.
- In the Explorer view, you should now see main.c file which you can open to look at the example code.
- To build the example, switch to CMake view in the left sidebar. When you move your mouse over the servo > servo in the tree, you should see a Build button. Clicking it will build the program.
- To run the program, I use Picoprobe, so I go to the Run and Debug view and click the green Start Debugging button at the top.
- You can also upload the program to your Pico by dragging and dropping the .uf2 file to the virtual drive the Pico creates when you connect it to your computer. Uncomment the
pico_add_extra_outputs(servo)
line in CMakeLists.txt to generate the .uf2 file during build. You will then find the .uf2 file in the /build sub-folder of the program folder.
Wiring the Things
I assume you know how to connect servo and RC receiver to the Pico, but here is the basic wiring for one servo and an RC receiver as used for the Control example.
Note that the RC servos and receivers are designed to work with 5 V but in my setup, I power them from the 3V3 pin of the Pico without a problem. You could use the VSYS pin instead, which would be the 5V from the USB. I use the VSYS pin to power the target Pico from another Pico which works as a debug probe (as described in the Getting started with Raspberry Pi Pico document), so it was easier for me to use 3V3 pin.
Here is my testing setup.
Using the Library
In the following sections, I will describe how to work with servos and RC receiver using the pico_rc
library.
Servo
To work with servo, you first need to create a servo "object
":
rc_servo myServo1 = rc_servo_init(SERVO1_PIN);
It is actually a C language structure, not C++ object, that's why the quotes. The SERVO1_PIN
is number of the Pico pin you want to use to control the servo. For example:
#define SERVO1_PIN 6
Next, you should call rc_servo_start
like this:
rc_servo_start(&myServo1, 90);
You pass the servo "object
" myServo1
(the address of it, to be exact, note the &) as the first argument, and the desired angle the servo should move to (90 degrees) as the second argument. To move the servo to another position, you then call rc_servo_set_angle
, for example:
rc_servo_set_angle(&myServo1, angle);
There is also a function to set the pulse width in microseconds rather than angle in degrees, rc_servo_set_micros
. If you have no idea what I am talking about, look at the explanation in the Points of Interest section below.
Here is complete code of the Servo example included in the library.
#include <stdio.h>
#include "pico/stdlib.h"
#include "pico/rc.h"
#define SERVO1_PIN 6
#define SERVO2_PIN 7
int main(void) {
setup_default_uart();
printf("Servo example for rc library\n");
rc_servo myServo1 = rc_servo_init(SERVO1_PIN);
rc_servo myServo2 = rc_servo_init(SERVO2_PIN);
rc_servo_start(&myServo1, 90); rc_servo_start(&myServo2, 180);
uint angle = 0;
bool up = true;
while ( 1 ) {
if ( up ) {
angle++;
if ( angle == 180 )
up = false;
}
else {
angle--;
if ( angle == 0 )
up = true;
}
rc_servo_set_angle(&myServo1, angle);
rc_servo_set_angle(&myServo2, 180 - angle);
sleep_ms(25);
} return 0;
}
RC Receiver
To get input from an RC receiver, you first need to initialize the input like this:
rc_init_input(CHANNEL1_PIN, true);
The CHANNEL1_PIN
is the number of the pin where you connected your receiver output channel. For example:
#define CHANNEL1_PIN 2
The true
argument in rc_init_input
tells the library that it should start monitoring the input pin right away. You could also pass false
, if you plan to setup more inputs before starting to monitor them. In such case, you would use rc_set_input_enabled
to start monitoring each input at a later time.
Once the input is initialized and enabled, you can read the pulse width on this input by calling rc_get_input_pulse_width(pin number)
:
uint32_t pulse = rc_get_input_pulse_width(CHANNEL1_PIN);
The function rc_get_input_pulse_width
will return the width of the pulse in microseconds (us).
It takes a single argument - the number of the Pico pin. Naturally, this should be the pin you previously initialized with rc_init_input
.
The library measures the pulses in the background using interrupts, so your program is not blocked by calling rc_get_input_pulse_width
. The function immediately returns the last pulse width that was seen on the input.
You can monitor up to RC_MAX_CHANNELS
input pins at the same time. By default, it is 5
, but it can be easily changed, as described in the section on configuring the library.
If you are curious how the pulses are actually measured, see the Points of Interest section.
Note that the value returned by rc_get_input_pulse_width
can be zero, if there are no pulses, or if the pulses are out of valid range. Apart from obvious problems like connecting the receiver to a wrong pin, this can also happen if your receiver loses the signal from the transmitter. You may want to detect this situation, so you can do something like set the outputs to a fail-safe values. This is the reason why the function rc_reset_input_pulse_width
exists in the library. Detecting the signal loss is shown in the complete example below.
#include <stdio.h>
#include "pico/stdlib.h"
#include "pico/rc.h"
#define CHANNEL1_PIN 2
#define CHANNEL2_PIN 3
#define CHANNEL3_PIN 4
int main() {
setup_default_uart();
printf("Example for rc library\n");
rc_init_input(CHANNEL1_PIN, true);
rc_init_input(CHANNEL2_PIN, true);
rc_init_input(CHANNEL3_PIN, true);
while (1)
{
uint32_t pulse = rc_get_input_pulse_width(CHANNEL1_PIN);
printf("Pulse ch1= %lu\n", pulse);
pulse = rc_get_input_pulse_width(CHANNEL2_PIN);
printf("Pulse ch2= %lu\n", pulse);
pulse = rc_get_input_pulse_width(CHANNEL3_PIN);
printf("Pulse ch3= %lu\n", pulse);
rc_reset_input_pulse_width(CHANNEL1_PIN);
sleep_ms(30); pulse = rc_get_input_pulse_width(CHANNEL1_PIN);
if ( pulse == 0 )
printf("Channel 1 disconnected\n");
sleep_ms(400);
}
return 0;
}
Configuring the library
There are some parameters you can configure to customize the library. To set the value of each parameter, just define it in the CMakeLists.txt like this:
# SPECIFY preprocessor definitions for this project
target_compile_definitions(servo PRIVATE
RC_SERVO_MIN_PULSE=1100
RC_SERVO_MAX_PULSE=1900
)
In this example, we override the default minimum and maximum widths of a pulse sent to servo.
The following parameters can be defined. For default values, please see rc.c file.
RC_MAX_CHANNELS
RC_MIN_PULSE_WIDTH
RC_MAX_PULSE_WIDTH
RC_SERVO_MIN_PULSE
RC_SERVO_MAX_PULSE
RC_SERVO_MIN_ANGLE
RC_SERVO_MAX_ANGLE
Hopefully, the names are self-explanatory.
Note that the servo and receiver have their own min and max values for the pulse width. For RC receiver, the values actually define what is considered a valid pulse. If the measured pulse width is shorter than the RC_MIN_PULSE_WIDTH
or longer than RC_MAX_PULSE_WIDTH
, the library will report 0 for the pulse width.
Points of Interest
This section describes in more or less details how the library works. It is not necessary to know all this, but as usual, sometimes it may be useful.
Hobby Servo
You probably know how RC servos are controlled. If not, please check one of the many articles on this, for example, here.
Just to sum it up, servos are controlled by a PWM (pulse-width modulated) signal. The width of the pulse is 1 millisecond for one end position of the servo, and 2 ms for the other end position.
The pulses repeat with 20 ms period, so the frequency is 50 Hz.
To control a servo, you need to generate PWM signal on an output pin with 50 Hz frequency and duty between 5 and 10 percent (1 to 2 ms). This is relatively easy to do with the PWM functions of the Pico C SDK.
One thing you should know is the term "slice". The Pico has 8 hardware PWM modules, each capable of controlling two pins (so, you can control 16 pins in total). This also means that two pins always share one PWM unit. The sub-unit which controls one pin is called a slice.
RC Receiver
An RC receiver works like this - it receives commands over the air from the transmitter (the box with sticks or a wheel you hold in you hand). The receiver translates these "air commands" to the standard PWM signal with 1 to 2 ms pulses repeating every 20 ms. Normally, you connect servos to the RC receiver and control them with the transmitter. In our case, we connect the Pico to the receiver to process the commands as needed.
For example, you may have a two-wheeled (differential wheeled) robot which you want to control with your RC car transmitter. So you need to translate the commands for one receiver channel (steering) to handle the two motors of the robot so that it turns as required.
The task of our RC library is to find out how long the pulse on an input pin is, and provide this information to the user code.
How do you measure the width of an input pulse?
If you know Arduino, you probably answer pulseIn()
. There is no pulseIn
in Pico but don't be sad, the function is evil anyway. Why? It blocks the program and waits for the input pin to go from LOW to HIGH and back to LOW, and returns how long the pin was high. This is fine if you need to measure pulses on one pin. But usually, you need more than one input from the RC receiver, like throttle and steering for an RC car. Now what will you do? Probably this:
channel1 = pulseIn(PIN1);
channel2 = pulseIn(PIN2);
channel3 = pulseIn(PIN3);
etc..
Is there a problem?
Maybe. There is no standard for RC receivers to say in which order the pulses for different channels follow each other (like channel 1 first, channel 2 next etc.). Neither is it guaranteed that the pulses will not overlap. Some receivers may even start the pulses at the same moment instead of one after another.
In most cases, the above code will work, but very likely you will be skipping some pulses while you wait, say, for a pulse on channel 1, while the receiver is sending pulse on channel 2 or 3. It may take up to 60 ms before you obtain all three readings if you are unlucky enough to start the pulseIn
for a given channel just after the pulse on that channel finished and have to wait for the next pulse to appear.
Blocking the program for such a long time may be a problem.
How can we do it on Pico and better? In the Pico C SDK, there is measure_duty_cycle
example in the PWM category in pico-examples which shows how to use the Pico PWM hardware to measure input signal. It may look like the thing we need, but it is not suitable for our purpose.
The PWM hardware in Pico can only do two things with inputs. It can count input pulses (incrementing a counter when the logic level on input pin changes) or it can increment the counter as long as the input pin is HIGH.
This latter option is used in the measure_duty_cycle
example. It measures the input for 10 ms, then checks what value the counter reached during these 10 ms. We know the speed of the counter (we set it), so we know the maximum value it can reach in 10 ms. For example, if the counter is running at 10 kHz, it can reach 100 in 10 ms. If you read the counter and see it is 20, you know that the duty is 20 / 100, that is, 20 per-cent.
I would call this a statistical approach to measuring the duty cycle. It is fine for fast signals. If there are hundreds or more periods in those 10 ms, the result is good. But it will not work once you measure slow signals, like the RC signal. The problem is that you can start or end that 10 ms measuring interval in the middle of a pulse. Obviously, the width of that pulse is then not measured correctly. If there are many pulses in those 10 ms, this error can be ignored. But if there are just a few pulses, the error can affect the result significantly.
For example, consider an input signal with 5 ms period and 2 ms pulses, that is 40% duty. If you measure is for 10 ms, in ideal case (if you start measuring just when the pulse stars), you get two whole pulses, that is the counter is running for 4 ms of 10 ms, that is 40% - your result is correct.
But suppose you start in the middle of a pulse, so you "count" it for 1 ms instead of 2 ms. Then after 5 ms, another pulse comes, which is counted correctly for 2 ms. In total, you get 3 ms out of 10 ms, and calculate that the duty is 3/10, about 30 per-cent. Your result is wrong.
The SDK example measures PWM signal with frequency of some 120 kHz so there is no such problem. Unfortunately, some people will use the example without understanding the principle for much lower frequencies and wonder why their results are not so good.
The RC signal is so slow, that you can see a problem without much thinking. It has a period of 20 ms and pulses from 1 to 2 ms wide. If you measure for 10 ms, you may sometimes get nothing and sometimes you get something. But how do you know that you caught the pulse from the beginning? What if you happen to start measuring in the middle of a pulse, or towards the end of a pulse. In both cases, you get shorter pulse than it really is. You would need to measure for much longer than 10 ms to get a good result. But you do not want to block your program for so long; even 10 ms is too long. You do not want to imitate the pulseIn
.
So I looked for other way to handle the pulse measurement. This seems to be using GPIO interrupts. Pico pins can trigger an interrupt when the level on the pin changes. This is pretty common feature for many microcontrollers.
Here is how it works in the library:
- Set GPIO pin to trigger an interrupt when pin level changes.
- In the interrupt service routine (ISR) save current time obtained by
get_absolute_time
Pico SDK function on rising edge - when the pin goes from low to high; that is when the pulse starts. - On falling edge, calculate the current time minus the saved start time to see how long the pulse was. If it is within valid range, save the result.
This happens in the background, in interrupts handlers. The ISR saves the pulse width into an array gRcInputChannels
where each input pin has its own data (see the rc_channel_info struct
in rc.c).
User of the library calls rc_get_input_pulse_width
which returns the latest pulse width.
Note that the library uses so-called raw irq handler for the interrupts. This is because the normal handler is shared by all pins, so if the library defined this handler, the user code could not use GPIO interrupts. With raw handlers, there is no such problem, as these are defined per pin. So user code can still use GPIO interrupts.
Conclusion
The code of the library is on Github.
I am only learning to work with Raspberry Pi Pico, but so far I like it a lot. The C SDK is nicely designed and the documentation is excellent. Considering also the price and computing power of the Raspberry Pico board, I think Arduino has a serious competitor as the board of choice for DIY projects.
History
- 8th May, 2023 - First version