使用vofa+上位机进行PID调参(附代码STM32F407VET6开发板Hal库开发)
使用ST32F407VET6开发板,基于Hal库,通过上位机vofa+进行PID参数的整定调整
一、vofa+上位机介绍
vofa+是一款面向嵌入式开发领域的上位机软件,它支持Windows/Linux/macOS平台,提供串口(超高波特率稳定支持)、网口(TCP/UDP)等通信接口,核心功能是通过图形化交互实现传感器数据实时可视化与分析。
viofa+采用插件驱动架构,协议与控件开源均可自定义,内置三大协议:
- FireWater:类似printf的CSV字符串协议(如下所示)
编程简单但是资源消耗较高,适合低速场景。"1.23,4.56\n" - JustWater:小端浮点数组+帧尾的二进制协议(如0x00,0x00,0x80,0x7F),高效低开销,推荐用于MCU多通道高速数据传输
- RawData:原始字节流模式,兼容传统串口助手功能。
其图形控件库支持拖拽式添加波形图、3D模型(可导入STL文件)、按钮、滑块等。在侵入式调试中,尤其适合PID调参、飞控姿态监控、实时数据监控等场景,大幅度提升开发效率。本次重点内容便是通过滑块的模式改变PID的参数,是PID调参更加快捷。
vofa+完全开源免费,下载的官网为:VOFA-Plus上位机 | VOFA-Plus上位机
二、使用vofa+调整PID参数
2.1vofa+界面
vofa+的初始化界面如下所示:

vofa+的界面大致可以分为三部分:协议与连接,图形显示界面和串口调试界面。可以根据自己的需要选择对应数据引擎,数据接口一般选择串口模式,串口参数配置根据自己实际情况选择,波特率自己根据需要配置,本人选用的是115200bit/s,常使用的还有9600bit/s。图形显示界面默认为空的,串口调试界面与常用的串口调试助手使用方式一样。
串口调试界面常用的命令如下图所示:

左侧最下边的图表为控件,点开它,可以将控件图形移动到图形显示界面,本次需要的是波形图和最下变的slider,移动完后的图形显示界面如下所示:

可以根据自己的需要更改对应的名称,本次将其修改为Kp,Ki,Kd。
2.2命令配置
拖动滑条实现PID参数的调整,其本质就是每滑动一次,由上位机发送一个自定义的串口数据包,下位机进行数据包的解析,得到实时的Kp,Ki和Kd值,因此需要将这三个控件绑定好对应的命令格式,使其能够发送数据包。
命令的绑定在左侧第三个绿色图表,点开后点击添加新的命令。

双击命令可进入编辑模式,更改命令的名称为Kp,并且可以自定义发送的内容,连续发送参数选择默认的命令即可。下面详细讲述一下命令的发送指令数据包编写。
串口的数据接收是按位接收,比如发送的是字符串“abc”,则串口接收的形式是按位接收字符‘a’,‘b’,‘c’,因此我们利用串口接收的特性,自定义数据包的格式如下:KP%fM,KI%fM,KD%fM。具体分析如下:
- K是数据包的包头;
- P,I,D则是标志位,用来判断具体是PID的哪一个参数;
- %f则是对应的浮点型数据,也是真正需要赋值接收的参数值;
- M是数据包的包尾,用来判断数据接收完毕。
将发送的内容写好之后,变可以将命令与对应的控件进行绑定,Kp绑定Kp,Ki绑定Ki,Kd绑定Kd。

操作完成后,注意将自己所配置的控件内容进行保存,否则下次再次打开仍需重新配置。

2.3编写代码思路
整体编写思路如下所示:
- 对上位机发送的数据包进行接收;
- 对数据包进行解析,除去包头和包尾,得到需要的数据内容;
- 将字符串格式的数据通过库函数转换为浮点型数据;
- 将得到的数据分别赋值给Kp,Ki和Kd。
数据包的接收采用的是状态机的变成思路,定义两个状态,分别是接收的状态(用来指示接收数据到哪一步)和接收标识的状态(用来指示接收的是Kp,Ki还是Kd)
static enum{
Wait_Head,//等待包头
Wait_Flag,//等待接收标识
Wait_Data //等待接收数据
}RxState=Wait_Head;//初始状态为等待包头
static enum{
CMD_NONE, //空状态
CMD_Kp, //Kp
CMD_Ki, //Ki
CMD_Kd //Kd
}CurrentCmd=CMD_NONE;//初始为空状态
状态机的实现流程图如下所示:

在代码编写中,需要关注一个C语言函数strtof,将字符串转换为单精度浮点数float,该函数的解析规则如下:
- 跳过前导空白字符(空格、制表符等);
- 识别符号:支持
+正数,默认)或-(负数); - 解析数字格式:
- 十进制格式(如 123.456);
- 科学计数法(如1.23e4);
- 十六进制格式(如0x1F);
- 遇到非法字符停止:例如字母、非数字符号等;
uint8_t *endptr;//二级指针,用于返回转换结束位置(指向首个未转换字符的地址)
float NewValue = strtof((char*)RxPacket, (char**)&endptr);
//(char*)RxPacket:将uint8_t数组(原始数据缓冲区)转换为 char* 类型,符合nptr参数要求
//(char**)&endptr:将uint8_t*的地址转换为char**类型,使endptr能正确存储未转换字符的位置。
(这一处函数如果不太清楚,直接拿过来使用即可)
基于STM32F407VET6的串口中断回调函数编写:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if(huart==&huart1)
{
static uint8_t RxData; //定义接收的数据
static uint8_t RxIndex; //储存数据数组的索引值
static uint8_t RxPacket[128]; //储存数据的数组
static enum{
Wait_Head, //等待包头
Wait_Flag, //等待接收标识
Wait_Data //等待接收数据
}RxState=Wait_Head;//初始状态为等待包头
static enum{
CMD_NONE, //空状态
CMD_Kp, //Kp
CMD_Ki, //Ki
CMD_Kd //Kd
}CurrentCmd=CMD_NONE;//初始为空状态
switch(RxState)//使用switch语句使得结构更加清晰
{
case Wait_Head:
if(RxData=='K')//接收到包头‘K’
{
RxState=Wait_Flag;//跳转转态至等待接收标识
}
break;
case Wait_Flag:
if(RxData=='P')//接收到标识‘P’
{
CurrentCmd=CMD_Kp;
RxState=Wait_Data;
RxIndex=0;//数组清零,便于下一次开始接受实际数据内容
}
else if(RxData=='I')//接收到标识‘I’
{
CurrentCmd=CMD_Ki;
RxState=Wait_Data;
RxIndex=0;
}
else if(RxData=='D')//接收到标识‘D’
{
CurrentCmd=CMD_Kd;
RxState=Wait_Data;
RxIndex=0;
}
else//均为接收到,则重置状态
RxState=Wait_Head;
break;
case Wait_Data:
if(RxData=='M')//接收到包尾‘M’
{
RxPacket[RxIndex]='\0';//为接收的数组添加上结束符‘\0’
uint8_t *endptr;
float NewValue = strtof((char*)RxPacket, (char**)&endptr);//双重转换
// 验证转换有效性
if(endptr!=RxPacket && *endptr == '\0')
{
switch(CurrentCmd)//判断接收标识符
{
case CMD_Kp:
PID_K[0]=NewValue;//PID_K[0]=Kp
printf("Kp updated:%.2f\n",PID_K[0]);
break;
case CMD_Ki:
PID_K[1]=NewValue;//PID_K[0]=Ki
printf("Ki updated:%.2f\n",PID_K[1]);
break;
case CMD_Kd:
PID_K[2]=NewValue;//PID_K[0]=Kd
printf("Kd updated:%.2f\n",PID_K[2]);
break;
case CMD_NONE:
break;
}
}
else
printf("Error:%s\n",RxPacket);//接收错误
RxState=Wait_Head;
CurrentCmd=CMD_NONE;//状态重置
}
else
{
if(RxIndex<sizeof(RxPacket)-1)//判断数组是否越界
{
RxPacket[RxIndex++]=RxData;//将接收到的数据存储至数组中
}
else
{
RxState=Wait_Head;//溢出复位
}
}
break;
default:
RxState=Wait_Head;
break;
}
HAL_UART_Receive_IT(&huart1,&RxData,1);//重启串口接收
}
}
注意事项:
1、RxData,RxIndex,RxPacket设置为静态变量,使其仍可保存之前存储的数据。
2、本代码在串口中断中使用printf函数,可能会占用内存资源,可以在设置输出标志位在主函数中进行输出。
3、在实际调整参数中,调到合适的值之后,需要对原程序里的Kp,Ki,Kd初始值赋给自己所调好的值,否则每次烧录初始化,Kp,Ki和Kd的值还是之前设置的初始化未调好的值。
在USART函数中需要对printf函数进行重定向:
int fputc(int ch, FILE *f)
{
while ((USART1->SR & 0X40) == 0);
USART1->DR = (uint8_t)ch;
return ch;
}
完整代码:
/* USER CODE BEGIN Header */
/**
******************************************************************************
* @file : main.c
* @brief : Main program body
******************************************************************************
* @attention
*
* Copyright (c) 2025 STMicroelectronics.
* All rights reserved.
*
* This software is licensed under terms that can be found in the LICENSE file
* in the root directory of this software component.
* If no LICENSE file comes with this software, it is provided AS-IS.
*
******************************************************************************
*/
/* USER CODE END Header */
/* Includes ------------------------------------------------------------------*/
#include "main.h"
#include "adc.h"
#include "dma.h"
#include "tim.h"
#include "usart.h"
#include "gpio.h"
/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include "stm32f4xx_it.h"
#include "tim.h"
#include "stdio.h"
#include "OLED.h"
#include "pid.h"
#include "stdlib.h"
/* USER CODE END Includes */
/* Private typedef -----------------------------------------------------------*/
/* USER CODE BEGIN PTD */
/* USER CODE END PTD */
/* Private define ------------------------------------------------------------*/
/* USER CODE BEGIN PD */
/* USER CODE END PD */
/* Private macro -------------------------------------------------------------*/
/* USER CODE BEGIN PM */
/* USER CODE END PM */
/* Private variables ---------------------------------------------------------*/
/* USER CODE BEGIN PV */
/* USER CODE END PV */
/* Private function prototypes -----------------------------------------------*/
void SystemClock_Config(void);
/* USER CODE BEGIN PFP */
/* USER CODE END PFP */
/* Private user code ---------------------------------------------------------*/
/* USER CODE BEGIN 0 */
uint8_t RxData;
float PID_K[3]={1.0,1.0,1.0};//Kp,Ki,Kd
/* USER CODE END 0 */
/**
* @brief The application entry point.
* @retval int
*/
int main(void)
{
/* USER CODE BEGIN 1 */
/* USER CODE END 1 */
/* MCU Configuration--------------------------------------------------------*/
/* Reset of all peripherals, Initializes the Flash interface and the Systick. */
HAL_Init();
/* USER CODE BEGIN Init */
/* USER CODE END Init */
/* Configure the system clock */
SystemClock_Config();
/* USER CODE BEGIN SysInit */
/* USER CODE END SysInit */
/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_DMA_Init();
MX_TIM1_Init();
MX_ADC1_Init();
MX_TIM2_Init();
MX_TIM3_Init();
MX_USART1_UART_Init();
/* USER CODE BEGIN 2 */
OLED_Init();
HAL_UART_Receive_IT(&huart1,&RxData,1);
/* USER CODE END 2 */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
OLED_ShowFloatNum(0,0,PID_K[0],2,3,OLED_8X16);
OLED_ShowFloatNum(0,16,PID_K[1],2,3,OLED_8X16);
OLED_ShowFloatNum(0,32,PID_K[2],2,3,OLED_8X16);
OLED_Update();
}
/* USER CODE END 3 */
}
/**
* @brief System Clock Configuration
* @retval None
*/
void SystemClock_Config(void)
{
RCC_OscInitTypeDef RCC_OscInitStruct = {0};
RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};
/** Configure the main internal regulator output voltage
*/
__HAL_RCC_PWR_CLK_ENABLE();
__HAL_PWR_VOLTAGESCALING_CONFIG(PWR_REGULATOR_VOLTAGE_SCALE1);
/** Initializes the RCC Oscillators according to the specified parameters
* in the RCC_OscInitTypeDef structure.
*/
RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSI;
RCC_OscInitStruct.HSIState = RCC_HSI_ON;
RCC_OscInitStruct.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT;
RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSI;
RCC_OscInitStruct.PLL.PLLM = 8;
RCC_OscInitStruct.PLL.PLLN = 168;
RCC_OscInitStruct.PLL.PLLP = RCC_PLLP_DIV2;
RCC_OscInitStruct.PLL.PLLQ = 4;
if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK)
{
Error_Handler();
}
/** Initializes the CPU, AHB and APB buses clocks
*/
RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_SYSCLK
|RCC_CLOCKTYPE_PCLK1|RCC_CLOCKTYPE_PCLK2;
RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;
RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV4;
RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV2;
if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_5) != HAL_OK)
{
Error_Handler();
}
}
/* USER CODE BEGIN 4 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if(huart==&huart1)
{
static uint8_t RxData;
static uint8_t RxIndex;
static uint8_t RxPacket[128];
static enum{
Wait_Head, //等待包头
Wait_Flag, //等待接收标识
Wait_Data //等待接收数据
}RxState=Wait_Head;//初始状态为等待包头
static enum{
CMD_NONE, //空状态
CMD_Kp, //Kp
CMD_Ki, //Ki
CMD_Kd //Kd
}CurrentCmd=CMD_NONE; //初始为空状态
switch(RxState)
{
case Wait_Head:
if(RxData=='K')
{
RxState=Wait_Flag;
}
break;
case Wait_Flag:
if(RxData=='P')
{
CurrentCmd=CMD_Kp;
RxState=Wait_Data;
RxIndex=0;
}
else if(RxData=='I')
{
CurrentCmd=CMD_Ki;
RxState=Wait_Data;
RxIndex=0;
}
else if(RxData=='D')
{
CurrentCmd=CMD_Kd;
RxState=Wait_Data;
RxIndex=0;
}
else
RxState=Wait_Head;
break;
case Wait_Data:
if(RxData=='M')
{
RxPacket[RxIndex]='\0';
uint8_t *endptr;
float NewValue = strtof((char*)RxPacket, (char**)&endptr); // 双重转换
// 验证转换有效性
if(endptr!=RxPacket && *endptr == '\0')
{
switch(CurrentCmd)
{
case CMD_Kp:
PID_K[0]=NewValue;
printf("Kp updated:%.2f\n",PID_K[0]);
break;
case CMD_Ki:
PID_K[1]=NewValue;
printf("Ki updated:%.2f\n",PID_K[1]);
break;
case CMD_Kd:
PID_K[2]=NewValue;
printf("Kd updated:%.2f\n",PID_K[2]);
break;
case CMD_NONE:
break;
}
}
else
printf("Error:%s\n",RxPacket);
RxState=Wait_Head;
CurrentCmd=CMD_NONE;
}
else
{
if(RxIndex<sizeof(RxPacket)-1)
{
RxPacket[RxIndex++]=RxData;
}
else
{
RxState=Wait_Head;
}
}
break;
default:
RxState=Wait_Head;
break;
}
HAL_UART_Receive_IT(&huart1,&RxData,1);
}
}
/* USER CODE END 4 */
/**
* @brief This function is executed in case of error occurrence.
* @retval None
*/
void Error_Handler(void)
{
/* USER CODE BEGIN Error_Handler_Debug */
/* User can add his own implementation to report the HAL error return state */
__disable_irq();
while (1)
{
}
/* USER CODE END Error_Handler_Debug */
}
#ifdef USE_FULL_ASSERT
/**
* @brief Reports the name of the source file and the source line number
* where the assert_param error has occurred.
* @param file: pointer to the source file name
* @param line: assert_param error line source number
* @retval None
*/
void assert_failed(uint8_t *file, uint32_t line)
{
/* USER CODE BEGIN 6 */
/* User can add his own implementation to report the file name and line number,
ex: printf("Wrong parameters value: file %s on line %d\r\n", file, line) */
/* USER CODE END 6 */
}
#endif /* USE_FULL_ASSERT */
三、功能展示
OLED屏实时显示

演示视频
vofa+上位机调PID
四、总结
如果搭配上无线link,便可实现无线模式调参。尤其对于小车这种运动系统而言,可以实时调整PID参数,方便快捷。这也是本人第一次写博客,里面有些内容理解可能不足,请批评指正,谢谢!
更多推荐




所有评论(0)