2025寒假练--基于STM32G031的虚拟仪器实现(上位机基于MATLAB APP编写)
该项目使用了C语言、STM32CubeMX软件、Keil v5软件,实现了STM32G031简易示波器与简易信号发生器的设计,它的主要功能为:利用STM32G031的其中两路ADC通道采集外部电压信号,将12位AD值打包为八位数据帧并通过串口发送至PC端(含自定义通信协议);通过与OLED屏幕的交互实现对输入信号的衰减倍率的更改与简单的信号发生。。 该项目使用了Matlab编程语言,实现了示波器在PC端的Matlab上位机的设计,它的主要功能为:通过串口接收来自STM32G031端发送的数据帧,将数据帧在PC端进行解析与处理,完成电压波形与FFT频谱的显示、电压波形基本参数的测量;波形保持与简易的触发功能。。
标签
嵌入式系统
STM32
ADC
开发板
寒假在家一起练
空楼醉雨
更新2025-03-13
南昌大学
384

一、项目介绍

1.项目要求(2025寒假练任务二)

虚拟仪器指的是将测试设备同电脑连接,利用电脑强大的数据处理能力和大屏幕上能进行丰富的波形和信息显示,构成一个完整的测试系统,测试设备端则不需要太强的处理能力以及设备上的波形显示和控制。

虚拟仪器已经成为电子产品系统测量中的一个重要分支。DIY一个简易口袋仪器能够掌握很多电路设计、嵌入式编程以及测试测量的基本原理,DIY一个虚拟仪器,能够将知识和技能进一步延伸 - 它涉及到USB的数据通信、PC上的软件编程、数据处理等。

基于STM32的简易示波器/频谱仪/信号发生器学习平台这款平台虽然自身带有按键/编码器的输入,以及OLED显示屏进行波形和参数的显示,可以作为一个简易的仪器来使用,在2022年的寒假在家一起练的活动中,很多同学都实现了双通道示波器、频谱分析、信号产生等功能,虽然板上的屏幕分辨率只有128*128点阵,而输入控制也仅有2个按键和一个编码器。

在2025年寒假练中将这个平台同PC连接起来,利用PC的数据处理能力和大屏幕显示,能够实现更好的测试测量效果,具体要求如下:

  1. 使用STM32G031平台进行数据采集及信号产生(可以使用板上的OLED显示和按键/编码器控制,也可以不使用)
  2. 通过USB同PC进行数据传输
  3. 在上位机上编写简单的仪器控制界面,实现双通道示波器和单通道信号发生器的功能 - 显示波形以及基本的参数(至少显示4个参数):
    1. 被测信号的峰峰值和平均值
    2. 被测信号的周期
    3. FFT后的频谱
    4. ....

虽然寒假在家没有测量仪器进行辅助,仍然可以使用板上的麦克风电路,将外部声音转换成电信号作为采集对象,另外信号产生电路生成的电信号,也可以作为数据采集的信号源。

使用板卡:STM32G031示波器板卡

2.项目效果展示

本项目采用了硬禾推出的STM32G031简易示波器/频谱仪/信号发生器学习平台。经过寒假与开学前两周共计30余天的知识储备、代码编写、调试与再完善,最终基本达到了项目预期。以下是该项目的实际效果展示:

硬件端UI功能界面展示:

79666cac321a6ec10e049d4a4fd8e4a.jpg

初始化界面:显示项目信息(图一)

3c2cb89236152807c0842644427c064.jpg

界面一:提示电压采集状态与不同通道输入信号的衰减倍率(图二)

c5b82c9faa29c66dc516b5adb3c7664.jpg

界面二:控制信号发生器开关、调整波形参数(图三)

可以使用按键、旋转编码器与单片机交互,通过OLED显示相关参数信息。界面一下可以更改输入信号的衰减倍率;界面二下可以产生指定波形的信号并调整相关参数——旋转编码器(旋钮)顺/逆时针旋转可切换需要更改参数的对象;旋转编码器按键(旋钮按键)可更改所选目标参数值;按键(左下)可切换至功能界面一;按键(右下)可切换至功能界面二。当处于界面一时:可通过顺时针或逆时针旋转旋钮选择要更改衰减倍率的通道;选定目标通道后单击旋钮按键可在”×01”与“×05”衰减倍率之间进行切换;当处于界面二时,可通过顺时针或逆时针旋转旋钮选择要变更的信号发生器工作参数,例如信号发生器工作与否、所需产生的波形种类、增大波形幅值以及频率值。

硬件端完成对外部电压信号(以12位AD值的形式)的实时采集与串口发送(借助串口助手展示)

image.png

(图五)

上图为通过串口调试小助手接收到的硬件端数据——若干由帧头、转为十六进制的两个通道的AD值、帧尾组成的八位数据帧。这是本虚拟仪器项目得以实现的根基,也是后续Matlab上位机端数据解析、处理与实时画图的核心依据,将在核心原理讲解部分详细介绍。

软件端完成对单片机发送数据的实时解析与绘图展示

a2a0abdda7b9c0860e996b797bdad86.jpg

信号发生器产生的正弦波参数(图六)

image.png

上位机端实时显示的电压波形、FFT后的频谱图与相关参数(图七)

硬件端信号发生器功能展示

efa37e0d4e51a0ab936d7e8596aa6fb.jpg

硬件端开启信号发生器功能,并将波形切换为方波(图八)

2b00f53cdbc5f35a57a30fc2a2fc67f.jpg

利用手持示波器校验硬件端产生的波形(图九)



二、核心原理讲解

1.硬件端程序结构

系统逻辑框图总览:

(图十)

主要函数调用关系:

(图十一)

主要应用逻辑层:

(图十二)

中断处理流程:

(图十三)

页面处理逻辑:

(图十四)

2.硬件端核心配置与代码

单片机双通道ADC采样与串口发送的CubeMx配置流程

  1. 配置TIM2的相关参数。由于外部输入电压信号是连续不断的,对采集的实时性要求极高,故可以定时器二的更新事件作为ADC采样的依据或时基,从而实现对采样率更加灵活与实时的控制(虽然最终没能实现对采样率的动态调控)。如图十五所示,在.3处配置定时器的TRGO为更新事件(Update Event),以便后续将ADC转换配置为外部触发源定时器二更新事件触发(如图十六.6所示)。由于ADC转换配置为了定时器二更新触发,故定时器二的更新频率直接影响单片机ADC转换的触发频率,因此ADC时钟频率除以.1与.2的乘积所得到的结果即为单片机的ADC转换触发频率。例如此处.1与.2分别配置为了(640-1)与(100-1),而ADC时钟频率为64MHz,故ADC转换触发频率为64000000/(640-1)/(100-1)=1000Hz。
  2. 配置ADC转换的相关参数。根据原理图,在ADC配置页面勾选通道一(IN1)与通道七(IN7);为了使示波器能够进行双通道采样,将图十六.4配置为ADC扫描转换使能,同时在规则组处为通道一与通道七注册;由于已经配置了定时器二更新事件(图十六.6)触发ADC转换,故仅需保证采样周期与12.5倍的ADC时钟周期之和(1.5Cycles+12.5Cycles)不大于定时器二更新的周期即可,此处(图十六.5)配置为了1.5Cycles。
  3. 配置ADC的DMA通道。由于AD值的采样与发送是实时进行、容不得长时间耽误的,其对实时性的要求极高,因此如果只是使用主程序一直接管这一过程,则系统其他对实时性有一定需求的进程或任务将迟迟无法被执行,如按键或旋钮相应、界面切换等,这时我们就要用到ADC的DMA通道。如图十七,为ADC1添加DNA通道一、方向为从外设(ADC采样)到内存(以数组的形式保存)、传输字宽为半字长(uint16_t)、DMA的非循环模式,即Normal模式。因为本项目采用ADC转换完成中断(HAL_ADC_ConvCpltCallback)触发下一次的DMA传输。
  4. 配置串口通信相关参数。将串口二配置为异步通信,波特率115200,其他配置默认;打开串口二发送的DMA1通道3,方向为内存至外设,其他配置默认。使用DMA的原因与步骤3同。

image.png

定时器二的参数设置(图十五)

image.png

ADC的参数设置(图十六)

image.png

ADC的DMA配置(图十七)

单片机双通道ADC采样与串口发送的核心代码段(含注释)

1.sample代码段

/*=====sample.h=====*/
#ifndef __SAMPLE_H__
#define __SAMPLE_H__

#include "main.h"
#include "tim.h"
#include "adc.h"
#include "dma.h"
#include "stdio.h"
#include "string.h"
#include "usart.h"

#pragma pack(push, 1) // 确保结构体紧凑排列
typedef struct {
uint8_t header[2]; // 0xFF 0xFF
uint16_t ch1; // 通道1数据
uint16_t ch2; // 通道2数据
uint8_t footer[2]; // 0x0D 0x0A
} DataPacket;
#pragma pack(pop)

extern uint16_t adc_value[2];
extern DataPacket adc_packet;
extern char message[100];

void send_data_packet(void);
void sample1_init(void);
void sample2_init(void);
void sample_deinit(void);

#endif


/*=====sample.c=====*/
#include "sample.h"

uint16_t adc_value[2]; //用于接收ADC采样值(ADC_DMA1的专用Half Word接收数组)
char message[100] = ""; //串口调试观察双通道十进制的12位AD值时使用
DataPacket adc_packet =
{
.header = {0xFF, 0xFF}, //定义数据帧头
.footer = {0x0D, 0x0A} //定义数据帧尾
};

/* ×1衰减倍率下电压采样初始化 */
void sample1_init(void)
{
__HAL_TIM_SET_COMPARE(&htim16, TIM_CHANNEL_1, 333-1); //重设定时器16的PWM比较值
__HAL_TIM_SET_COMPARE(&htim14, TIM_CHANNEL_1, 333-1); //重设定时器14的PWM比较值
HAL_TIM_PWM_Start(&htim16, TIM_CHANNEL_1); //×1衰减倍率下,通道一Bias 1.1V
HAL_TIM_PWM_Start(&htim14, TIM_CHANNEL_1); //×1衰减倍率下,通道二Bias 1.1V

HAL_ADCEx_Calibration_Start(&hadc1); //ADC校准
HAL_TIM_Base_Start(&htim2); //开启定时器二,给ADC采集提供外部触发事件
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_value, 2); //开启DMA搬运adc数据
}

/* ×5衰减倍率下电压采样初始化 */
void sample2_init(void)
{
__HAL_TIM_SET_COMPARE(&htim16, TIM_CHANNEL_1, 455-1);
__HAL_TIM_SET_COMPARE(&htim14, TIM_CHANNEL_1, 455-1);
HAL_TIM_PWM_Start(&htim16, TIM_CHANNEL_1); //×5衰减倍率下,通道一Bias 1.5V
HAL_TIM_PWM_Start(&htim14, TIM_CHANNEL_1); //×5衰减倍率下,通道二Bias 1.5V

HAL_ADCEx_Calibration_Start(&hadc1); //ADC校准
HAL_TIM_Base_Start(&htim2); //开启定时器二,给ADC采集提供外部触发事件
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_value, 2); //开启DMA搬运adc数据
}

/* 当设置信号发生器参数时,调用该函数暂时停止ADC采样 */
void sample_deinit(void)
{
HAL_TIM_PWM_Stop(&htim16, TIM_CHANNEL_1); //停止Bias
HAL_TIM_PWM_Stop(&htim14, TIM_CHANNEL_1); //停止Bias

HAL_TIM_Base_Stop(&htim2); //停止定时器二
HAL_ADC_Stop_DMA(&hadc1); //停止DMA搬运adc数据
}

/* 填充数据与串口发送函数 */
void send_data_packet(void)
{
//装填帧头与帧尾
adc_packet.ch1 = adc_value[0];
adc_packet.ch2 = adc_value[1];

//发送完整数据包
HAL_UART_Transmit_DMA(&huart2, (uint8_t*)&adc_packet, sizeof(DataPacket));
}

2.stm32g0xx_it代码段(ADC传输完成中断)

/*=====stm32g0xx_it.c=====*/

/* . . . . . . */

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
if(hadc->Instance == ADC1)
{
//调试语句
/*printf("DMA Transfer Complete");
sprintf(message, "CH1:%-4d CH2:%-4d\r\n", adc_value[0], adc_value[1]);
HAL_UART_Transmit(&huart2, (uint8_t*)message, strlen(message), 100);*/
send_data_packet(); //发送数据包

HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_value, 2); //重启DMA,搬运adc数据
}
}

/* . . . . . . */

3.关于自定义通信协议

本项目单片机向PC端发送的数据格式为[0xFF] [0xFF] [CH1_H] [CH1_L] [CH2_H] [CH2_L] [0x0D] [0x0A],即:帧头FF、帧头FF、通道一AD值高位、通道一AD值低位、通道二AD值高位、通道二AD值低位、帧尾0D、帧尾0A。帧头帧尾可以避免在识别单片机发送的数据时造成混淆或错误的“链式反应”,而且可以有效避免传输过程中的产生数据丢失(数据帧长度校验异常时会自动舍去这组数据)。至于为什么帧尾采用[0D]与[0A],这就要谈到Matlab上位机编程中的回调机制和串口接收回调函数了。

% serialport 对象表示与串行端口通信的串行客户端
app.s = serialport(app.port_using, 115200)

% 配置帧尾(终止位)
configureTerminator(app.s,"CR/LF");

% 配置自定义回调
configureCallback(app.s, "terminator", @(src,event)app.dataReceivedCallback(src,event));

温馨提示:本人使用的Matlab版本为2023b,而serialport函数系2019b版本后产生的、对旧版serial函数的优化升级版,故用法与配套函数有些许差别,在使用Matlab实现上位机的串口连接时需要特别注意。

Matlab的函数回调机制:可以与STM32的hal库函数回调机制相类比,都是当在特定的事件或条件发生时由另外的一方调用的、用于对该事件或条件进行响应的函数,一般情况下在使用时都需要经过用户的重定义。

Matlab中的串口接收回调配置:可以通过使用函数configureTerminator(device,terminator)定义与指定的串行端口进行读写通信的终止符(帧尾)。允许的终止符值是 “LF”(默认值)、“CR”、“CR/LF” 以及 0 到 255的整数值。此处使用的终止符是“CR/LF”,这也是本项目中将帧尾设置为0D0A的原因——CR(回车)和LF(换行)对应的ASCII值分别为0x0D与0x0A。配置完通信终止符后就需要利用configureCallback函数的configureCallback(device,“terminator”,callbackFcn)形式配置串口回调函数了。其中device为已经定义过的串口对象、字节回调属性“terminator”指由帧尾触发串口接收回调、callbackFcn为目标回调函数——总的来说,configureCallback将回调函数 callbackFcn 设置为每当可从指定的串行端口读取终止符时触发。经过以上三步便可以完成串口连接成功的前提下对接收到数据的回调解析与处理了。当然,在进行以上串口回调函数的配置之前,首要任务是完成对电脑已经连接的窗口号的识别与匹配(见“4.软件端核心代码”)。

3.软件端程序结构

(图十八)

4.软件核心代码

如何识别到设备端口号?

%=====串囗号获取函数——原作者:B站up主Monkey蒙=====%
function [COMS, port] = get_com_ports()
command = 'wmic path win32_pnpentity get caption /format:list | find "COM"';%系统指令——关键!
[status, cmdout] = system(command); %接收system函数返回的两个值 有端口连接时显示为0否则为1
cmdout = strread(cmdout, '%s', 'delimiter', '='); %按分隔符拆分字符串数组
if numel(cmdout) > 0
j = 1;
for i = 1 : numel(cmdout) %nuel 返回元素个数
if strcmp(cmdout{i}(1:7), 'Caption')
COMS{j}= cmdout {i+1};
j = j + 1;
end
end
COMS_split = cell2mat(COMS);
COMS_split = split(COMS_split, '('); %通过左括号将字符串分割
j = 1;
port_temp = {}; % 初始化port_temp
for i = 1: numel(COMS_split) %numel 返回元素个数
if strcmp(COMS_split{i}(1:3), 'COM')
port_temp{j} = COMS_split{i};
j = j + 1;
end
end
port_temp = split(port_temp, ')'); %通过右括号将字符串分割
j = 1;
port = {}; % 初始化port
for i = 1 : (numel(port_temp) - 1) %numel 返回元素个数
if strcmp(port_temp{i}(1:3),'COM')
port{j} = port_temp{i};
j =j + 1;
end
end
elseif numel(cmdout) == 0
COMS = "没有搜索到串口";
port = "NULL";
errordlg('没有搜索到任何可用端口');
end
end

此处学习借鉴了B站up主Monkey蒙发布的视频——【Matlab串口通信之获取设备端口号[毕设如此简单之上位机篇(四)]】中的代码。但是由于up主使用的Matlab版本较低,为了适配20223b版本的Matlab上位机编程,在调用该函数时需要对函数返回值进行一定筛选和数据类型转换,否则将会报出数组索引超标”、数据类型不匹配等错误,具体处理代码段如下:

% Button pushed function: RefreshButton
function RefreshButtonPushed(app, event)
[app.COMS, app.port] = get_com_ports(); %自定义函数调用
%app.DropDown.Items = app.COMS; %“数组索引超标“主要原因在此,索性放弃使用串口全称
app.DropDown.Items = app.port; %用app.port赋值给下拉菜单的Item,
app.port_using_cell = app.port(1); %若需要全局使用则加上"app."
app.port_using = app.port_using_cell{1, 1}; %将函数返回值app.port的数据类型转换为元胞数组,否则会报错“数据类型不匹配”
disp(app.port_using);
disp(class(app.port_using));
end

如何解析来单片机的八位数据帧为电压值?

%=====串口接收回调函数=====%
function dataReceivedCallback(app, ~, ~)
persistent buffer startIdx
% 完整接收整段数据帧
if isempty(buffer)
buffer = uint8([]);
end
% 读取原始数据
rawData = read(app.s, app.s.NumBytesAvailable, "uint8");
buffer = [buffer; rawData(:)];
% 窗口输出原始数据,便于调试观察
fprintf('接收数据: %s\n', dec2hex(buffer));
%%%%%主循环%%%%%
% 协议解析:[FF] [FF] [CH1_Low] [CH1_High] [CH2_Low] [CH2_High] [0D] [0A]
while length(buffer) >= 8
% 查找包头
startIdx = find(buffer(1:end-1) == 0xFF & buffer(2:end) == 0xFF, 1, 'first');
if isempty(startIdx) || (length(buffer) - startIdx) < 7
break; % 数据不足
end
% 提取完整数据包
packet = buffer(startIdx:startIdx+7);
buffer(1:startIdx+7) = [];
% 验证包尾
if ~isequal(packet(end-1:end), [0x0D; 0x0A])
continue;
end
% 解析ADC
ch1_raw = typecast(uint8(packet(3:4)), 'uint16');
ch2_raw = typecast(uint8(packet(5:6)), 'uint16');
% 转换为电压值(以硬件端1V1电压偏置为基准)
voltage_ch1 = ((double(ch1_raw) / 4095 * 3.3) - 1.65) / (-0.5);
voltage_ch2 = ((double(ch2_raw) / 4095 * 3.3) - 1.65) / (-0.5);
% 乒乓缓冲存储
if app.CurrentBuffer
app.PingBuffer1.data(app.PingBuffer1.idx,:) = [voltage_ch1, voltage_ch2];
app.PingBuffer1.idx = app.PingBuffer1.idx + 1;
% 第一缓冲区触发条件:当缓存满240帧触发处理
if app.PingBuffer1.idx > 240 && ~app.IsProcessing
app.IsProcessing = true;
%=调用数据处理与绘图函数=%
processAndPlot(app);
end
else
app.PingBuffer2.data(app.PingBuffer2.idx,:) = [voltage_ch1, voltage_ch2];
app.PingBuffer2.idx = app.PingBuffer2.idx + 1;
end
% 第二缓冲区触发条件:当缓存满240帧触发处理
if app.PingBuffer2.idx > 240 && ~app.IsProcessing
app.IsProcessing = true;
%=调用数据处理与绘图函数=%
processAndPlot(app);
end
end
end

此部分的难点在于确保串口与上位机连接的绝对成功,这是代码能否继续编写下去的根基(自己当时在这里卡了大概三天,甚至一度想要投靠VS C#的上位机开发)。对数据帧的解析代码比较常规,值得注意的是对于乒乓缓冲区的理解与应用,即:数据接收与处理分开进行。

如何对解析完成的数据进行处理与绘图(包含触发与波形保持逻辑)?

%=====数据处理与显示函数=====%
function processAndPlot(app)
% 切换缓冲指针
if app.CurrentBuffer
plotData = app.PingBuffer1.data;
app.PingBuffer1.idx = 1;
else
plotData = app.PingBuffer2.data;
app.PingBuffer2.idx = 1;
end

app.CurrentBuffer = ~app.CurrentBuffer;
app.IsProcessing = false;
% 触发检测逻辑
if app.Trigger.Enable && ~app.Trigger.Found
% 获取触发通道数据
triggerData = plotData(:, app.Trigger.Channel);
% 查找触发点
for i = 2:length(triggerData)
prev = triggerData(i-1);
curr = triggerData(i);
% 触发边沿条件判断 %
if strcmp(app.Trigger.Slope, 'R')
condition = (prev < app.Trigger.Level) && (curr >= app.Trigger.Level);
else
condition = (prev > app.Trigger.Level) && (curr <= app.Trigger.Level);
end
if condition
% 计算触发位置索引
posIdx = round(app.Trigger.Position/100 * size(plotData,1));
startIdx = max(1, i - posIdx);
endIdx = min(size(plotData,1), startIdx + 199);
% 锁定触发数据
app.Trigger.DataLock = plotData(startIdx:endIdx, :);
app.Trigger.Found = true;
break;
end
end
end

%=判断是否处于波形保持状态=%
if app.IsHolding
% 存储到临时缓冲(限制最大1000点)
app.HoldBuffer.ch1(end+1) = voltage_ch1;
app.HoldBuffer.ch2(end+1) = voltage_ch2;
app.HoldBuffer.count = app.HoldBuffer.count + 1;
% 缓冲区溢出保护
if app.HoldBuffer.count > 1000
app.HoldBuffer.ch1 = app.HoldBuffer.ch1(end-999:end);
app.HoldBuffer.ch2 = app.HoldBuffer.ch2(end-999:end);
app.HoldBuffer.count = 1000;
end
else
% 判断是否找到触发点
if app.Trigger.Found
%=调用图像更新函数=%
updateDisplay(app, app.Trigger.DataLock(:,1), app.Trigger.DataLock(:,2));
else
%=调用图像更新函数=%
updateDisplay(app, plotData(:,1), plotData(:,2));
end
end
end

受限于个人水平,此部分代码的触发部分仍然存在较大缺陷,体现在以下两点:a.开启触发模式后并不能实现完美的触发,波形显示存在异常;b.无法更改触发方式(上升沿/下降沿触发)。

得到连续的电压值后怎样进行图像更新?

%======图像更新显示=====%
function updateDisplay(app, ch1, ch2)
maxPoints = 200;
% 初始化波形数据
if isempty(app.waveformData)
%app.waveformData = nan(maxPoints, 2); % 初始化为NaN——存在FFT计算错误与无法绘图的问题,故不使用
app.waveformData = zeros(maxPoints, 2); % 存储200个点的双通道历史数据;使用NaN预分配避免初始零值干扰
end
% 更新数据缓冲区(替换旧数据)
app.waveformData = [app.waveformData(2:end,:); [ch1, ch2]];
% ------------------- 动态波形显示 --------------------
% 首次运行时初始化线条
if isempty(app.lineCh1) || ~isvalid(app.lineCh1)
cla(app.UIAxes); % 确保坐标系干净
hold(app.UIAxes, 'on');
app.lineCh1 = plot(app.UIAxes, app.waveformData(:,1), 'b', 'DisplayName','CH1');
app.lineCh2 = plot(app.UIAxes, app.waveformData(:,2), 'r', 'DisplayName','CH2');
hold(app.UIAxes, 'off');
else
% 只更新数据点,不新建对象
app.lineCh1.YData = app.waveformData(:,1); % 更新Y轴数据
app.lineCh1.XData = 1:size(app.waveformData,1); % X轴范围调整
app.lineCh2.YData = app.waveformData(:,2);
app.lineCh2.XData = 1:size(app.waveformData,1);
end
% 设置示波器x轴滚动效果,以及想展示的点集区
xlim(app.UIAxes, [max(1, size(app.waveformData,1)-100), size(app.waveformData,1)]);
% ------------------- UI刷新 --------------------
drawnow limitrate % 限制刷新速率
% 调用面板显示参数计算函数 %
updateParameters(app, app.waveformData(:,1), app.waveformData(:,2));
end

此部分需要解决的问题是app.waveformData = zeros(maxPoints, 2)函数的自身缺陷。将初始值赋值为零而带来的峰峰值计算错误——因为将初始值赋0必然会导致在一段时间内波形图上显示的电压值为零,但实际上此时并没有接收到电压数据(应当为nan),从而看上去就好像电压在上位机启动的一瞬间发生了突变。起初是想使用app.waveformData = nan(maxPoints, 2)函数将初始值全部赋为nan,但是奈何又出现了fft计算异常与频谱图无法显示的问题。最后采取的折中解决方案是引入plot refresh按钮控件,并且在每次上位机启动后,先点击该按钮(见图二十)将包含零电压值的图像清空后再进行后续的数据观测。PS:Plot Refresh按钮控件具体代码详见”附件下载“部分。

image.png

未按下Plot Refresh按钮时通道二的Vpp读数与波形存在问题(图十九)

image.png

按下该按钮后Vpp的读数与初始波形的显示相对正常(图二十)

得到连续的电压值后怎样进行相关电路参数的计算?

%=====面板显示参数更新函数=====%
function updateParameters(app, data1, data2)
% 峰峰值
vpp1 = max(data1) - min(data1);
vpp2 = max(data2) - min(data2);
% 平均值
vavg1 = mean(data1);
vavg2 = mean(data2);
% 周期检测(简易过零法)%
zeroCross1 = find(diff(sign(data1 - vavg1)) ~= 0);
if length(zeroCross1) >= 2
period1 = (zeroCross1(end) - zeroCross1(end-1)) / length(data1);
else
period1 = NaN;
end

zeroCross2 = find(diff(sign(data2 - vavg2)) ~= 0);
if length(zeroCross2) >= 2
period2 = (zeroCross2(end) - zeroCross2(end-1)) / length(data2);
else
period2 = NaN;
end

%FFT变换算法
L1 = length(data1);
Y1 = fft(data1);
P1_2 = abs(Y1/L1);
app.P1_1 = P1_2(1:L1/2+1);
app.P1_1(2:end-1) = 2*app.P1_1(2:end-1);
app.f1 = app.s.BaudRate*(0:(L1/2))/L1;

L2 = length(data2);
Y2 = fft(data2);
P2_2 = abs(Y2/L2);
app.P2_1 = P2_2(1:L2/2+1);
app.P2_1(2:end-1) = 2*app.P2_1(2:end-1);
app.f2 = app.s.BaudRate*(0:(L2/2))/L2;

% 更新UI标签显示
app.VppLabel_1.Text = sprintf('Vpp_1: %.3f V', vpp1);
app.VavgLabel_1.Text = sprintf('Avg_1: %.3f V', vavg1);
app.PeriodLabel_1.Text = sprintf('T_1: %.3f ms', period1*1000);
app.VppLabel_2.Text = sprintf('Vpp_2: %.3f V', vpp2);
app.VavgLabel_2.Text = sprintf('Avg_2: %.3f V', vavg2);
app.PeriodLabel_2.Text = sprintf('T_2: %.3f ms', period2*1000);

% FFT频谱分通道显示
if app.FFTSwitch_Value == "CH1"
plot(app.SpectrumAxes, app.f1, app.P1_1, 'b', 'DisplayName','CH1');
elseif app.FFTSwitch_Value == "CH2"
plot(app.SpectrumAxes, app.f2, app.P2_1, 'r', 'DisplayName','CH2');
end
xlim(app.SpectrumAxes, [0 10000]); % 限制显示在10kHz以下(手动修改)
end

此部分本质上就是原来应当在单片机上实现的代码(2022年寒假在家练)在Matlab上的改进与复现。得益于许多Matlab中内嵌好的数学函数,如maxminmeanfindfftabs等,大部分复杂算法仅需进行应用层面上的调用,本部分程序的编制才不至于过于复杂。

三、遇到的问题、难点以及解决方案

由于上位机相关代码编写过程中遇到的大部分问题及解决方案已经在第二大部分尽数提及,下面对前文已经提及到的软件端遇到的问题仅作概括而不赘述,而是重点谈谈硬件端代码困扰我至少5天的一大问题与解决方案——旋转编码器的消抖。

说到旋转编码器的消抖,配套视频中的消抖方案是将旋转编码器的A相设置为外部中断、B相设置为输入捕获,在A相触发上升沿中断时利用Hal_Delay()延时1ms后读取B相电平高低,但实际上并不可行,实现代码和不可行的原因如下所示:

////法一:使用了HAL_Delay(1),直接卡死程序
/*无法实现的表现是,写入这段代码程序会直接阻塞,oled和按键全部失效;
原因就在于HAL_Delay(1)。它的实现用到了sistick,其实现又有赖于systick timer的中断,
而此处也用到了外部(下降沿)中断。其systick timer中断优先级若不进行配置则默认为最低,
从而与此处外部中断发生冲突,故导致程序阻塞。这进一步说明了中断函数内不要使用延时的必要性。
*/
void HAL_GPIO_EXTI_Rising_Callback(uint16_t GPIO_Pin) //用于判断旋钮左转or右转
{
switch (GPIO_Pin)
{
case KEY3_EXIT15_Pin:
HAL_Delay(1); //旋钮消抖时间不可太长,否则会影响相位判断!
//此处是以KEY3_EXIT15_Pin为基准,即KEY3为基准,判断KEY5_Input电平。其中KEY3配置为了中断模式而KEY5为输入模式
if(HAL_GPIO_ReadPin(KEY3_EXIT15_GPIO_Port,KEY3_EXIT15_Pin) && HAL_GPIO_ReadPin(KEY5_Input_GPIO_Port,KEY5_Input_Pin))
{
HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_14);
printf("ROT Turn Left!\r\n");//二者电平一致时,即旋钮左转
else if (HAL_GPIO_ReadPin(KEY3_EXIT15_GPIO_Port,KEY3_EXIT15_Pin) && !HAL_GPIO_ReadPin(KEY5_Input_GPIO_Port,KEY5_Input_Pin))
{
printf("ROT Turn Right!\r\n");//二者电平不一致时,即旋钮右转
}
break;
}
}
}

由上可见,如果并不熟悉中断优先级的工作原理与配置,或者单纯地从编程习惯的好坏来说(比如在中断回调中使用延时函数),这或许并不是一个很好的解决方案。

由于以上问题是由延时1ms的函数引起的,于是我优先想到使用时间戳消抖的方法取代1ms的延时,具体代码实现与原理如下:

////法二:时间戳消抖法(作用不明显)
/* 理论上可以解决问题,但是实际操作中作用微乎其微
产生的原因未知,有可能是硬件条件实在太差 */
void HAL_GPIO_EXTI_Rising_Callback(uint16_t GPIO_Pin) // 用于判断旋钮左转or右转
{
// 静态变量,用于记录上一次旋钮旋转的时间戳,用于消抖
static uint32_t last_rot_time = 0;
// 获取当前时间戳
uint32_t current_time = HAL_GetTick();

// 判断触发中断的引脚是否为 KEY3_EXIT15_Pin,并且检查当前时间与上一次旋转时间的差值是否大于旋钮消抖时间 ROT_DEBOUNCE_DELAY
if (GPIO_Pin == KEY3_EXIT15_Pin && (current_time - last_rot_time > ROT_DEBOUNCE_DELAY))
{
// 读取双引脚状态以便后续判断相位差
uint8_t pinA = HAL_GPIO_ReadPin(KEY3_EXIT15_GPIO_Port, KEY3_EXIT15_Pin);
uint8_t pinB = HAL_GPIO_ReadPin(KEY5_Input_GPIO_Port, KEY5_Input_Pin);

// 此处是以 KEY3_EXIT15_Pin 为基准,即 KEY3 为基准,判断 KEY5_Input 电平。其中 KEY3 配置为了中断模式而 KEY5 为输入模式
if (pinA && !pinB)
{
// 如果 pinA 为高电平且 pinB 为低电平,则判断为右旋
rot_state = ROT_RIGHT;
}
else if (pinA && pinB)
{
// 如果 pinA 和 pinB 都为高电平,则判断为左旋
rot_state = ROT_LEFT;
}
// 更新上一次旋转的时间戳
last_rot_time = current_time;

// 根据旋转状态执行相应的操作
switch (rot_state)
{
// case ROT_LEFT: HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_14); break;
// case ROT_RIGHT: HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_14); break;
case ROT_LEFT:
printf("ROT Turn Left!\r\n");
break;
case ROT_RIGHT:
printf("ROT Turn Rigth!\r\n");
break;
default:
break;
}
}
}

但是也许是硬件条件较差(旋转编码器扭转时抖动过大),此方法在操作过程中误判(例如在使用串口输出旋转信息时,在快速的连续右转时会多次输出左转的提示)产生的概率依旧较大,远不能满足实际操作要求。

在这之后我又尝试了外部中断+定时器1ms中断的方法,但是还是因为各种未知的原因无法达到很好的效果,具体代码实现与原理如下:

////法三:外部中断+定时器中断消抖法(作用不明显)
/*原因未知,理论上可行,但是实际操作中作用微乎其微,可能是硬件条件实在太差*/
void HAL_GPIO_EXTI_Rising_Callback(uint16_t GPIO_Pin) // 用于判断旋钮左转or右转
{
if (rot_flag == 1)
{ // 1ms定时器产生的延时,而未使用hal库自带的延时函数

rot_flag = 0;

if (GPIO_Pin == KEY3_EXIT15_Pin)
{
// 读取双引脚状态以便后续判断相位差
uint8_t pinA = HAL_GPIO_ReadPin(KEY3_EXIT15_GPIO_Port, KEY3_EXIT15_Pin);
uint8_t pinB = HAL_GPIO_ReadPin(KEY5_Input_GPIO_Port, KEY5_Input_Pin);

// 此处是以 KEY3_EXIT15_Pin 为基准,即KEY3为基准判断KEY5_Input电平。其中 KEY3 配置为了中断模式而KEY5为输入模式
if (pinA && !pinB) rot_state = ROT_RIGHT;
else if (pinA && pinB) rot_state = ROT_LEFT;
else rot_state = ROT_IDLE;

switch (rot_state)
{
case ROT_LEFT: printf("ROT Turn Left!\r\n");break;
case ROT_RIGHT: printf("ROT Turn Rigth!\r\n"); break;
default: break;
}
}
}
}

在这之后我有点不自信了,于是开始在CSDN上寻找解决方案,但是其中一种外部中断(双边)消抖的方法引起了我的注意,详见这一篇CSDN博客江协科技STM32——旋转编码器计次(软件消抖)于是试着写了以下消抖逻辑:

////法四:外部中断(双边)消抖法
/*
*增加判断正、反转的条件——读取一个周期内的电平变化再进行判断,即首先将最小系统板的一个引脚与旋转编码器的A相连接,
*配置为外部中断,触发方式选择上升/下降双边沿触发,用A相的输出信号来触发中断,然后在A相下降沿触发第一次中断后读取B相电平,
*(注意将与B相相接的芯片引脚配置为外部输入)紧接着A相上升沿触发第二次中断后读取B相电平,结合两次读取到的电平来判断是正转还是反转。
*这种检测方法的原理是波形抖动时B相的电平保持不变。从A相的下降沿触发到上升沿触发期间若B相电平发生了变化,则可判定编码器转动;反之未转动。*/
/* 形式一:通用中断回调函数--由于g0芯片包中无通用中断回调函数,故不可行*/
uint8_t B_last_level = 0;
uint8_t B_now_level = 0;
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) // 通用EXTI回调用于判断旋钮左转or右转
{
// 检查中断源是否为 KEY3_EXIT15_Pin
if (GPIO_Pin == KEY3_EXIT15_Pin)
{
// 获取当前A相电平
uint8_t currentA = HAL_GPIO_ReadPin(KEY3_EXIT15_GPIO_Port, KEY3_EXIT15_Pin);

if (currentA == GPIO_PIN_RESET) /* A相先下降,触发第一次中断,检测并记录B相的电平状态 last */
{ // 记录B相旧的电平状态last
B_last_level = HAL_GPIO_ReadPin(KEY5_Input_GPIO_Port, KEY5_Input_Pin);
}
else if (currentA == GPIO_PIN_SET) /* A相后上升,检测此时B相的电平状态 now */
{
B_now_level = HAL_GPIO_ReadPin(KEY5_Input_GPIO_Port, KEY5_Input_Pin);
}
}
}

但是我惊奇地发现,stm32g0的hal库芯片包竟然没有HAL_GPIO_EXTI_Callback函数?!我一度认为可能是hal库自身头文件包含的问题,因为有少部分hal库的带有"__"前缀和带有“EX”字母的外部函数确实会出现不包含或声明就无法使用的清情况,但是经过各种尝试还是没能解决。最终还是去到了ST的官方交流社区与老外交流才发现原来这一版芯片的hal库函数中将这个函数细分为了HAL_GPIO_EXTI_Rising_Callback()和HAL_GPIO_EXTI_Falling_Callback()回调函数......于是便又有了下面的这一版消抖逻辑:

/* 形式二:使用上升沿和下降沿中断回调函数(当没有通用中断回调函数时) */
/*注意!
单片机上电后A、B相默认输出高电平,第一次转动后A相先产生下降沿再产生上升沿,所以应当先判断A相的下降沿,
再判断A相的上升沿。如果先判断上升沿并检测这时候的B相电平就会导致第一次向新方向旋转时无效!
*/
uint8_t B_last_level = 0;
uint8_t B_now_level = 0;
void HAL_GPIO_EXTI_Falling_Callback(uint16_t GPIO_Pin) // 下降沿中断回调
{
// 检查中断源是否为 KEY3_EXIT15_Pin (A相)
if (GPIO_Pin == KEY3_EXIT15_Pin)
{
// 记录B相旧的电平状态last
B_last_level = HAL_GPIO_ReadPin(KEY5_Input_GPIO_Port, KEY5_Input_Pin);
}

}

void HAL_GPIO_EXTI_Rising_Callback(uint16_t GPIO_Pin) // 上升沿中断回调
{
// 检查中断源是否为 KEY3_EXIT15_Pin (A相)
if (GPIO_Pin == KEY3_EXIT15_Pin)
{
B_now_level = HAL_GPIO_ReadPin(KEY5_Input_GPIO_Port, KEY5_Input_Pin);

/*判断旋转方向——只有当B相电平发生变化时才能说明发生了一次旋转,因为A相波形抖动时B相的电平保持不变,从而能够实现忽略抖动*/
if (B_last_level == GPIO_PIN_SET && B_now_level == GPIO_PIN_RESET)
{
// B相last为高、now为低,则即顺时针旋转
printf("ROT Turn Right!\r\n");
}
else if (B_last_level == GPIO_PIN_RESET && B_now_level == GPIO_PIN_SET)
{
// B相last为低、now为高,则即逆时针旋转
printf("ROT Turn Left!\r\n");
}
}
}

这一版在旋转较慢时误判次数已经大大减少,但在快速旋转时仍然存在误判。

到这里已经有些烦躁了,难道就没有除去定时器的编码器模式以外的完美的软件消抖代码吗??我停下了手头的工作,放空大脑了两天,在这期间学习了一些大二下要学的数电课程。当刚刚学到格雷码的时候,我下意识中觉得这个似乎和我之前遇到的什么东西有点相像,但是并没有太在意......直到我又开始手头的编码器消抖工作后,我才猛然意识到,编码器向左和向右转动的过程不就是一组有“方向”的格雷码吗?那么我是否可以在每次转动时A、B相高低的电平变化看作两位二进制数,并将其转换为十六进制,通过检测每次转动周期内读到的电平状态变化,一组脉冲检测4次,只要有一次不符合就完全舍去这一次旋转,那么我的误判概率是否会大大降低呢?于是便有了以下消抖逻辑:

/*
*其他说明:本处ENCODER_A与ENCODER_B均配置为了[Pull-up Input]模式!
*因为ENCODER_A并没有用到原来的外部中断双边触发模式,故回归了本来应该有的样子.
* (具体上拉还是下拉要结合原理图才能确定)
*/
uint8_t rot_read(void)
{
uint8_t pinA_level = 0x01; // 00 00 00 01 A相默认高电平
uint8_t pinB_level = 0x01; // 00 00 00 01 B相默认高电平
uint8_t state_0xAB = 0x03; // 00 00 00 11

pinA_level = HAL_GPIO_ReadPin(ENCODER_A_GPIO_Port, ENCODER_A_Pin);
pinB_level = HAL_GPIO_ReadPin(ENCODER_B_GPIO_Port, ENCODER_B_Pin);
state_0xAB = ((pinA_level & 0x01) << 1) | (pinB_level & 0x01);

return state_0xAB;
}

void rot_proc(void)
{
static uint8_t last_AB_state = 0x03; // 上一次的AB相状态 初始时默认为 00 00 00 11 千万别忘!
static uint8_t left_cnt = 0; // 左旋转计数
static uint8_t right_cnt = 0; // 右旋转计数

uint8_t now_AB_state; // 当前的AB相状态

if (encoder_flag == 1) //2ms定时器产生的延时
{
encoder_flag = 0;
now_AB_state = rot_read(); //每2ms读取AB相状态
/*左旋格雷码序列:11 10 00 01 ...*/
if( (last_AB_state == 0x03 && now_AB_state == 0x02) ||
(last_AB_state == 0x02 && now_AB_state == 0x00) ||
(last_AB_state == 0x00 && now_AB_state == 0x01) ||
(last_AB_state == 0x01 && now_AB_state == 0x03) )
{
right_cnt = 0; //方向互斥保护——检测到左转状态立即重置右旋转计数
left_cnt++;
last_AB_state = now_AB_state;
}
/*右旋格雷码序列:11 01 00 10 ...*/
else if((last_AB_state == 0x03 && now_AB_state == 0x01) ||
(last_AB_state == 0x01 && now_AB_state == 0x00) ||
(last_AB_state == 0x00 && now_AB_state == 0x02) ||
(last_AB_state == 0x02 && now_AB_state == 0x03) )
{
left_cnt = 0; //方向互斥保护——检测到右转状态立即重置左旋转计数
right_cnt++;
last_AB_state = now_AB_state;
}
// 无旋转
else
{
}
// 旋转计数达到4次,输出旋转方向、执行相应操作
if(left_cnt >= 4)
{
left_cnt = 0;
right_cnt = 0;
printf("Left Left!\r\n");
//其他操作...
}
if(right_cnt >= 4)
{
right_cnt = 0;
left_cnt = 0;
printf("Right Right!\r\n");
//其他操作...
}

}

}

实践证明,这种方法确实在主循环中压力较小的情况下完美解决了旋转时的误判问题,实现了丝滑的转动体验!但值得注意的是,由于以上方法已经去除了对外部中断的依赖,因此需要在主循环中一直调用。为了不过度占用资源,应当为其加上2ms定时器触发标志位减速;同时这也对我们合理分配主循环中的任务时间片提出了更高的要求......这里本人水平有限,暂时还没有接触嵌入式操作系统的相关知识,在主循环中加入UI显示逻辑后还是导致了旋转编码器旋转卡顿的问题......还请各位高手大佬多多指教!!

四、活动总结与心得

通过本次2025硬禾寒假在家练项目,充分巩固和强化了自己的嵌入式编程能力、提高了自己使用CubeMX工具的熟练程度、还让自己学会了一门新的编程语言和新的能力——Matlab编程语言和基于Matlab的上位机编写,为自己今后STM32项目开发以及其他项目开发打下了坚实基础。

除此之外,自己还学会了如何有效地利用网络提问和检索资料。例如当自己找不到G031系列芯片hal库中的外部中断回调函数HAL_GPIO_EXTI_IRQHandler(),但是查遍网络资料也没有结果时,我想到了在深夜登陆ST官方社区提问——这时候欧美那边正好上班,可能可以得到快速回复。虽然想到要和外国人交流心里有些没底,但是最后还是借助翻译软件的帮助,选择了最礼貌的翻译版本向他们提问了(如图二十五)。

b412843c1c5ce371cf814525d4ded4f.png

(图二十五)

果不其然,自己的发帖很快得到了恢复——原来是这款芯片配套的hal库函数中本身就不包含HAL_GPIO_EXTI_IRQHandler()函数,取而代之的是函数Rising和Falling,这真是一个令我意想不到的答案!如果我当时不迈出向外国友人提问的勇敢一步,也许这个问题我到现在也不会有答案吧!

72fa2d66f864ae5e73dfb7bbae5b0c3.png

(图二十六)




附件下载
Oscilloscope_App.zip
Matlab上位机程序
Virtualinstrument.hex
十六进制烧录文件
Virtualinstrument.zip
项目源代码
团队介绍
南昌大学23级测控技术与仪器邹浔安
团队成员
空楼醉雨
评论
0 / 100
查看更多
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号