
Reading a Joystick on STM32 using ChibiOS
2-axis and a key button
The joystick proposed here is much known between makers. It provides two axis and a key button and every axis is actually a potentiometer: that means axis data is analogue and we need to use ADC to read its positioning.

Potentiometers are provided of springs so, without forcing, wipers are approximately positioned in the centre of the two resistive elements. As this device is very simple to use, it is not easy find a related datasheet. Indeed, for the most of the applications, it would be useless. Anyway, joystick used in this demo is a very cheap one marked as “Keyes_SJoys” (See Fig.1).
Schematic and pin out
Our device has 5 pins:
- GND, connection to ground;
- +5V, should be meant as connection to VCC;
- VRx, X-axis wiper ;
- VRy, Y-axis wiper;
- SW, switch terminal.
Note that pins are almost the same for every 2-axis Joystick you can buy online. There are some variant which have two or no switch.
Referring to Fig.1 we can see that the pins of resistive elements are connected to +5V and GND, and switch is connected between SW and GND. As the ADC on STM32 samples voltages from 0 to 3.0V, +5V should be connected to DC 3V. Since the switch has no pull up resistor we must provide it: we should connect a resistor across VCC(with a resistance greater of that exhibited by switch, as example 20kΩ). The GPIO from STM32 could be configured to provide an internal pull-up or pull-down so we will just act on software.
Proposed demo
Proposed demo has three threads. One just makes a LED blinking, another samples voltage on VRx and VRyand read SW status and the last one (the main()) prints data on a sequential stream.
Notes on sampled data handling
We want to spent some words on sampled data as this case is a little bit more complicated than that faced in main ADC tutorial or slider potentiometer article. Indeed, here we are sampling data from two different channel pretending to handle data from each channel in separately. Let’s take a look to ADCConversionGroup:
/* * ADC conversion group. * Mode: Linear buffer, SW triggered. * Channels: IN0 IN1. */ static const ADCConversionGroup my_conversion_group = { FALSE, /*NOT CIRCULAR*/ MY_NUM_CH, /*NUMB OF CH*/ NULL, /*NO ADC CALLBACK*/ NULL, /*NO ADC ERROR CALLBACK*/ 0, /* CR1 */ ADC_CR2_SWSTART, /* CR2 */ 0, /* SMPR1 */ ADC_SMPR2_SMP_AN0(ADC_SAMPLE_144) | ADC_SMPR2_SMP_AN1(ADC_SAMPLE_144),/* SMPR2 */ ADC_SQR1_NUM_CH(MY_NUM_CH), /* SQR1 */ 0, /* SQR2 */ ADC_SQR3_SQ1_N(ADC_CHANNEL_IN0) | ADC_SQR3_SQ2_N (ADC_CHANNEL_IN1) /* SQR3 */ };
Our conversion group contemplates a non-circular sampling on a sequence of two channel (ADC_CHANNEL_IN0, ADC_CHANNEL_IN1). Looking at adcConver() calling we will see that we sample that group for 10 times. As our sample buffer has been declared as:
#define MY_NUM_CH 2 #define MY_SAMPLING_NUMBER 10 static adcsample_t sample_buff[MY_SAMPLING_NUMBER * MY_NUM_CH];
when conversion is done, we have a buffer of 20 samples organized in this way:
IN0_1 | IN1_1 | IN0_2 | IN1_2 | … | IN0_10 | IN1_10 |
For convenience of use a good idea is declaring sample buffer in this way:
#define MY_NUM_CH 2 #define MY_SAMPLING_NUMBER 10 static adcsample_t sample_buff[MY_SAMPLING_NUMBER] [MY_NUM_CH];
this way our buffer is organizes as:
IN0_1 | IN1_1 |
IN0_2 | IN1_2 |
… | … |
IN0_10 | IN1_10 |
In the first column we have ten samples from IN0 (i.e. PA0 and in my case X-axis), in the second from IN1 (PA1, Y-axis). At that point there are some self question that should be answered:
- What kind of value we should expect?
- Why we have sampled ten times each axis?
- What exactly means “the wipers are approximately positioned in the centre of resistive element”?
STM32 has 12-bit ADC that means sampled values are in the range 0 and 212 – 1 (i.e. 4095). We sampled data many times to find means and remove noise. If we are not applying any force on the cursor then wipers are in the rest position and we expect to read 0 from both axis. Smarter reader should figure out that this in not what happens: without noise nor mechanical inaccuracies (i.e. inaccuracies due to the springs that do not place the cursor exactly in the middle of the resistive element) we will read 2047 (2047.5 if it were possible). Indeed since the absolute ratings are [0, 4095] we are placed exactly in the center. To obtain something more “pleasant” we should subtract 2047 to read data. In this case absolute ratings will become [-2047, 2048]. In this case we could say that negative and positive available value ranges are balanced.
Unfortunately, since there are mechanical inaccuracies even subtracting 2047 we still have an offset. If we want make a good calibration we should get this offset before to start using joystick. We can define the offset like a difference between real and expected value. The offset is a systematic error and that means it is predictable and typically constant to the true value. That means we have to calculate offset just for once and than we will compensate it during the following measurements.
To make things more clear let’s do an example. We are reading 2029 from x-axis when cursor is in the rest position (but we was expecting 2047): that means offset is -18, indeed subtracting this offset and 2047 to our value we will obtain 0. After this operation absolute ratings are not anymore [-2047, 2048] but they become [-2029, 2066]. We can balance negative and positive ranges choosing two different multiplying factors. As example if we would to have a range like [-10000,+10000] we can achieve this goal multiplying negative values by (10000/2029) and positive values by (10000/2066). If everything has been explained well, next code should be clear:
/* PLAY Embedded demos - Copyright (C) 2014-2016 Rocco Marco Guglielmi This file is part of PLAY Embedded demos. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ /* Tested under ChibiOS/RT 3.0.1, Project version 1.0 */ #include "ch.h" #include "hal.h" #include "chprintf.h" #define MAX_VALUE 1000 #define HALF_ADC 2047 BaseSequentialStream * chp = (BaseSequentialStream *) &SD2; static bool flag = FALSE; static int32_t x_raw, y_raw, x_offset, y_offset, x_scaled, y_scaled; /*===========================================================================*/ /* ADC related code */ /*===========================================================================*/ /* * In this demo we want to use a single channel to sample voltage across * the potentiometer. */ #define MY_NUM_CH 2 #define MY_SAMPLING_NUMBER 10 static adcsample_t sample_buff[MY_SAMPLING_NUMBER] [MY_NUM_CH]; /* * ADC conversion group. * Mode: Linear buffer, SW triggered. * Channels: IN0 IN1. */ static const ADCConversionGroup my_conversion_group = { FALSE, /*NOT CIRCULAR*/ MY_NUM_CH, /*NUMB OF CH*/ NULL, /*NO ADC CALLBACK*/ NULL, /*NO ADC ERROR CALLBACK*/ 0, /* CR1 */ ADC_CR2_SWSTART, /* CR2 */ 0, /* SMPR1 */ ADC_SMPR2_SMP_AN0(ADC_SAMPLE_144) | ADC_SMPR2_SMP_AN1(ADC_SAMPLE_144),/* SMPR2 */ ADC_SQR1_NUM_CH(MY_NUM_CH), /* SQR1 */ 0, /* SQR2 */ ADC_SQR3_SQ1_N(ADC_CHANNEL_IN0) | ADC_SQR3_SQ2_N (ADC_CHANNEL_IN1) /* SQR3 */ }; /*===========================================================================*/ /* Generic code. */ /*===========================================================================*/ static THD_WORKING_AREA(waThd1, 256); static THD_FUNCTION(Thd1, arg) { (void) arg; chRegSetThreadName("LED blinker"); while(TRUE) { palTogglePad(GPIOA, GPIOA_LED_GREEN); chThdSleepMilliseconds(250); } } static THD_WORKING_AREA(waThd2, 512); static THD_FUNCTION(Thd2, arg) { unsigned ii; (void) arg; chRegSetThreadName("Joystick sampler"); /* * Setting up VRx and VRy pins. */ palSetPadMode(GPIOA, GPIOA_PIN0, PAL_MODE_INPUT_ANALOG); palSetPadMode(GPIOA, GPIOA_PIN1, PAL_MODE_INPUT_ANALOG); /* * Activates the ADC1 driver. */ adcStart(&ADCD1, NULL); /* Sampling data for offset computing */ adcConvert(&ADCD1, &my_conversion_group, (adcsample_t*) sample_buff, MY_SAMPLING_NUMBER); /* Computing mean removing noise */ x_raw = 0; y_raw = 0; for(ii = 0; ii < MY_SAMPLING_NUMBER; ii++){ x_raw += sample_buff[ii][0]; y_raw += sample_buff[ii][1]; } x_raw /= MY_SAMPLING_NUMBER; y_raw /= MY_SAMPLING_NUMBER; /* Computing offset */ x_offset = x_raw - HALF_ADC; y_offset = y_raw - HALF_ADC; while(TRUE) { /* Sampling data for demo purpose */ adcConvert(&ADCD1, &my_conversion_group, (adcsample_t*) sample_buff, MY_SAMPLING_NUMBER); /* Computing mean removing noise */ x_raw = 0; y_raw = 0; for(ii = 0; ii < MY_SAMPLING_NUMBER; ii++){ x_raw += sample_buff[ii][0]; y_raw += sample_buff[ii][1]; } x_raw /= MY_SAMPLING_NUMBER; y_raw /= MY_SAMPLING_NUMBER; /* Removing offset and centring range */ x_raw -= (HALF_ADC + x_offset); y_raw -= (HALF_ADC + y_offset); /* Resizing value properly */ if(x_raw < 0){ x_scaled = x_raw * MAX_VALUE / (HALF_ADC + x_offset); } else{ x_scaled = x_raw * MAX_VALUE / (HALF_ADC - x_offset); } if(y_raw < 0){ y_scaled = y_raw * MAX_VALUE / (HALF_ADC + y_offset); } else{ y_scaled = y_raw * MAX_VALUE / (HALF_ADC - y_offset); } /* Data ready */ flag = TRUE; } } /* * Application entry point. */ int main(void) { /* * System initializations. * - HAL initialization, this also initializes the configured device drivers * and performs the board-specific initializations. * - Kernel initialization, the main() function becomes a thread and the * RTOS is active. */ halInit(); chSysInit(); /* * Activates the serial driver 2 using the driver default configuration. */ sdStart(&SD2, NULL); chThdCreateStatic(waThd1, sizeof(waThd1), NORMALPRIO + 1, Thd1, NULL); chThdCreateStatic(waThd2, sizeof(waThd2), NORMALPRIO + 1, Thd2, NULL); /* * Setting up SW pin. */ palSetPadMode(GPIOA, GPIOA_PIN4, PAL_MODE_INPUT_PULLUP); /* * Normal main() thread activity, in this demo it checks flag status. If flag * is true, last value is printed and then flag is lowered. */ while (TRUE) { if (flag) { chprintf(chp, "PLAY Embedded + ChibiOS\\RT: Slider demo \r\n"); chprintf(chp, "X:%4d, Y:%4d ", x_scaled, y_scaled); if(palReadPad(GPIOA, 4) == PAL_LOW){ chprintf(chp, "BUTTON PRESSED"); } chprintf(chp, "\r\n"); flag = FALSE; chThdSleepMilliseconds(150); chprintf(chp, "\033[2J\033[1;1H"); } chThdSleepMilliseconds(1); } }
Project download
The attached demo has been tested under ChibiOS 20.3.x.
Hi! Thanks for this tutorial, the demo code works fine with a single potentiometer. I’m using St-nucleo f4 board f446zetx to be precise. I dont have a joystick setup so I’m using 10k trimming pots. The problem is, in nucleo board the pins are changed. only the PA0 pin works and it changes values for both x and y axis. If i connect my pot to PA1 readings doesnt change.
I want to use this pin3, pin_name A1, signal_name ADC, stm32_pin PC0, Function ADC123_IN10.
How do i do that? what are all the parameters that needs to be changed.?
I dont quite understand what AN0 , AN1, AN2 means, I’ve already saw the previous page “Using STM32 ADC with ChibiOS ADC Driver” I still dont understand. Can you please explain.
To change the channel in use you have to act on the GPIO mode and on the sample group configuration. There is an article on the ADC on the STM32. Take a look to the getting-started session of this blog.
Hello sir,
Thanks for above info.
Sir, We are trying to interface Logitech gamepad with STM32 f446re Nucleo Board.We have some problems due to the unavailability of the Logitech Library file. If you have any sample code for Logitech please can you share me.
Hello,
I am sorry but I have no idea how the gamepad works.