目录
- 项目介绍
- 硬件介绍
- 设计思路
- 各功能代码及说明
- SPWM波生成
- ADC采样
- FFT
- 获取按键动作
- OLED显示
- 系统顶层
- 功能展示
- OLED显示采样波形
- OLED显示频谱/失真度曲线
- 项目总结
👉工程文件及代码:参见【2022寒假在家练】基于STM32G031的失真度测试仪
👉 CSDN:工程源代码下载
👉 Github-KafCoppelia/DistortionMeter
项目介绍
本项目基于电子森林的STM32G031口袋仪器训练平台,基于CubeMX与Keil,实现了:
- 通过芯片的PWM+板上LPF电路生成频率在DC~20KHz,频率可调,并且幅度可调,从10mV~500mV正弦波信号;
- 将该信号通过Test端口连接到测试电路的输入端,通过运算放大器输入至ADC+DMA,对其进行量化处理;
- 计算该电路频谱(归一化幅值谱),与总谐波(THD);
- 在OLED上绘制了波形图及归一化幅值谱、失真度曲线(线性及对数坐标)。
硬件介绍
👉 电子森林-基于STM32的简易示波器/频谱仪/信号发生器学习平台
- 基于STM32G031微控制器,Arm Cortex M0+内核,主频为64MHz;
- 2个按键+1个光电旋转编码器用于控制输入;
- 1个SPI接口的OLED显示屏(128*128分辨率);
- 1路音频放大电路用于产生ADC的测试信号,并可作为测试电路使用;
一个蜂鸣器用于音效输出; - 1路基于PWM的DDS信号输出,用于产生测试信号(任意波形);
- 2路增益可调的模拟信号输入,通过12bits ADC采集2mVpp~30Vpp,带宽为100KHz的模拟信号;
基于STM32G031的测试测量学习套件的构成框图如上图所示。
设计思路
设计的整体结构框图如下图所示:
👉 整体结构参考:SCOPE-F072–基于STM32F072的多功能掌中仪器
由于没有上操作系统,初始化外设及OLED后,整体为一个循环,判断当前系统处于示波器或频谱/失真度状态(即OLED显示的内容是什么),再各判断是否为页初始化(初次进入该状态时会设置状态,之后相互切换就不会重置状态位),之后执行对应的操作。按键、旋钮的交互功能如上图箭头所示。
各功能代码及说明
该开发板电路图如图所示:
板卡左下角PB0产生PWM,可通过示波器测PWM测试点调试,连接左上角JP1的1、2(实物排针右两个),通过对ADC_M(PA0)做采样即可获取波形数据。
SPWM波生成
PWM信号源相关代码参见source.c/.h
SPWM波主要是调节一般PWM波的占空比,使输出波所占面积和对应正弦波面积相等。所以首先需要一组正弦波数据,可以通过Python等方式计算:
import numpy as npdef sin_wave(point, num):y = []for i in range(0, point):fz = num/2 * np.sin(np.pi/point*2*i) + num/2y.append(fz)return yif __name__ == "__main__":y = sin_wave(256, 256)print(y)y2 = []for dot in y:y2.append(round(dot))print(y2)
其中,point
为生成数据的点数,如128、256个;num
为生成数据的范围,表示从0~num。生成的数据可以static const uint16_t
储存:
#define SIGNAL_LENGTH 256
static const uint16_t sine_table[SIGNAL_LENGTH] = {128, 131, 134, 137, 141, 144, 147, 150, 153, 156, 159, 162, 165, 168, 171, 174, 177, 180, 183, 186, 188, 191, 194, 196, 199, 202, 204, 207, 209, 212, 214, 216, 219, 221, 223, 225, 227, 229, 231, 233, 234, 236, 238, 239, 241, 242, 244, 245, 246, 247, 249, 250, 250, 251, 252, 253, 254, 254, 255, 255, 255, 256, 256, 256,256, 256, 256, 256, 255, 255, 255, 254, 254, 253, 252, 251, 250, 250, 249, 247, 246, 245, 244, 242, 241, 239, 238, 236, 234, 233, 231, 229, 227, 225, 223, 221, 219, 216, 214, 212, 209, 207, 204, 202, 199, 196, 194, 191, 188, 186, 183, 180, 177, 174, 171, 168, 165, 162, 159, 156, 153, 150, 147, 144, 141, 137, 134, 131, 128, 125, 122, 119, 115, 112, 109, 106, 103, 100, 97, 94, 91, 88, 85, 82, 79, 76, 73, 70, 68, 65, 62, 60, 57, 54, 52, 49, 47, 44, 42, 40, 37, 35, 33, 31, 29, 27, 25, 23, 22, 20, 18, 17, 15, 14, 12, 11, 10, 9, 7, 6, 6, 5, 4, 3, 2, 2, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 3, 4, 5, 6, 6, 7, 9, 10, 11, 12, 14, 15, 17, 18, 20, 22, 23, 25, 27, 29, 31, 33, 35, 37, 40, 42, 44, 47, 49, 52, 54, 57, 60, 62, 65, 68, 70, 73, 76, 79, 82, 85, 88, 91, 94, 97, 100, 103, 106, 109, 112, 115, 119, 122, 125
};
将TIM3_CH3(PB0)设为PWM输出,prescaler
与counter period
暂且不管。从正弦波表至设置频率的SPWM波及振幅还需变换:
void Generate_Sine(void)
{uint16_t tim_period;uint16_t i;if(is_source_on())Sine_Stop();tim_period = 64000000 / SIGNAL_LENGTH / source_signal.frequency; // 25__HAL_TIM_SET_AUTORELOAD(&htim3, tim_period-1);for (i = 0; i < SIGNAL_LENGTH; i++)sine_value[i] = (sine_table[i]-128) * (tim_period-1) / 256 * source_signal.amplitude / 1650 + tim_period/2;if(!is_source_on())Sine_Start();
}
根据:
f S P W M = f s i n e ⋅ N f_{SPWM}=f_{sine}\cdot N fSPWM=fsine⋅N
由此算得TIM3的周期,通过__HAL_TIM_SET_AUTORELOAD()
设定不同频率正弦波下的SPWM的频率。此外,还需对正弦波表做归一化,将其直流偏置移动到1.65V(IO口输出最高3.3V),其中source_signal
为信号源参数的结构体,储存源信号的频率和幅度,开始/关闭产生正弦波调用
HAL_TIM_PWM_Start_DMA()
及HAL_TIM_PWM_Stop_DMA()
即可。
typedef struct
{uint32_t frequency;uint16_t amplitude;
} Source_Params;
/*-----------------*/
Source_Params source_signal = {.frequency = 1000, .amplitude = 500};void Sine_Start(void)
{ HAL_TIM_PWM_Start_DMA(&htim3, TIM_CHANNEL_3, (uint32_t*)sine_value, SIGNAL_LENGTH);set_source_on();
}void Sine_Stop(void)
{HAL_TIM_PWM_Stop_DMA(&htim3, TIM_CHANNEL_3);set_source_off();
}
由于当设置好输出频率及振幅后,传输至TIM3_CH3的AutoReload
值为周期性循环的,因此设置TIM3_CH3的DMA有利于提高CPU的效率:
Parameters | Value |
---|---|
Channel | 随意 |
Direction | Memory to Peripheral |
Priority | Medium |
Mode | Circular |
Increment Address | Memory |
Data Width of Peripheral | Word |
Data Width of Memory | Half Word |
ADC采样
ADC采样相关代码参见sample.c/.h
一些采样的全局变量,256个采样值,9档采样率,初始设置采样率下标:
#define SAMPLE_RATES_NUM 9
uint16_t ADC_Value[SAMPLE_POINTS];
static const uint32_t sample_rate_list[SAMPLE_RATES_NUM] = {2560, 5120, 10240, 20480, 40960, 81920, 102400, 204800, 409600};
static int8_t sample_rate_index = 5;
ADC部分参数设置如下,其余参数大致选默认的即可:
Parameters | Value |
---|---|
Clock Prescaler | /2 |
Resolution | 12-bit |
Data Aligment | Right |
SamplingTime Common 1 | 1.5 |
SamplingTime Common 2 | 1.5 |
N of Conversion | 1 |
External Trigger Conversion Source | Timer 1 Tigger Out Event 2 |
External Trigger Conversion Edge | Trigger detection on the falling edge |
ADC对PA0的采样使用TIM1触发,当TIM1出现下边沿时,开始或结束采样,此时TIM1的频率即为ADC实际采样率。设置TIM1_CH3 PWM Generation No Output,prescaler
与counter period
暂且不管,重点设置TRGO Parameters:
Parameters | Value |
---|---|
Master/Slave Mode | Disable |
Trigger Event Selection TRGO | Reset |
Trigger Event Selection TRGO2 | Update Event |
每次开始采样前,需要根据采样值设置TIM1的AutoReload与CCR值(占空比50%即可):
uint32_t Get_SampleRate(void)
{return sample_rate_list[sample_rate_index];
}
void Set_SampleRate(uint32_t sample_rate)
{__HAL_TIM_SET_AUTORELOAD(&htim1, 64000000 / sample_rate - 1);__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_3, 32000000 / sample_rate);
}
从ADC采集的数据经过DMA存入数组,DMA设置如下:
Parameters | Value |
---|---|
Channel | 随意 |
Direction | Memory to Peripheral |
Priority | High |
Mode | Normal |
Increment Address | Memory |
Data Width of Peripheral | Half Word |
Data Width of Memory | Half Word |
开启采样需要开启TIM1及ADC的DMA传输,注意开始采样前需要对ADC进行校准:
void Sample_Start(uint16_t *ADCValue)
{HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_3);HAL_ADCEx_Calibration_Start(&hadc1);HAL_ADC_Start_DMA(&hadc1, (uint32_t *)ADCValue, SAMPLE_POINTS);
}void Sample_Stop(void)
{HAL_TIM_Base_Stop(&htim1);HAL_TIM_Base_Stop_IT(&htim1);HAL_ADC_Stop_DMA(&hadc1);
}
FFT
FFT及频谱相关代码参见specturm.c/.h
及库`
该部分使用Adafruit_ZeroFFT库,选定做FFT点数,选择对应的窗函数,删去库多余的代码节约空间。
👉 Adafruit_ZeroFFT
通过调用ZeroFFT()
即可计算FFT:
memcpy(FFT_Value, ADC_Value, sizeof(FFT_Value));
ZeroFFT((int16_t*)FFT_Value, FFT_POINT)
原代码内的FFT最后计算舍去了虚部,只保留了实部,在此参考寒假在家一起练(1) - 有信号发生器功能的简易示波器的该部分代码,改为计算幅值谱,并修正直流分量,最后将整个FFT数组做归一化即可得到归一化幅值谱:
for (i = 0; i < length; i++)
{real = *pOut++;img = *pOut++;*pSrc++ = sqrt((int32_t)real * real + (int32_t)img * img);
}
source[0] /= 2;
通过计算后的频谱计算中心频率、获得某频率所在频谱下标调用FFT_BIN
、FFT_INDEX
即可:
float Get_ActualFreq(uint16_t *FFTValue, uint32_t sample_rate)
{return FFT_BIN(Get_SpectrumMax(FFTValue, 1), sample_rate, FFT_POINT);
}
uint8_t Get_SpectrumMax(uint16_t *FFTValue, uint8_t ignore_dc)
{uint8_t i;uint8_t temp_max_index = ignore_dc ? 2 : 0;for (i = ignore_dc ? 3 : 1; i <= FFT_POINT / 2; i++)if (FFTValue[i] > FFTValue[temp_max_index])temp_max_index = i;return temp_max_index;
}
重点计算THD,需要计算中心频率功率、高次谐波的功率和,作者计算了一定采样率下频谱包含的所有谐波的功率和,当然也可只取N次,但容易出现交互调整采样率后该谐波不在频谱内的意外(频谱所包含的频率只有Sample Rate/2
)。
float Get_THDx(uint16_t *FFTValue, uint8_t ignore_dc, uint32_t sample_rate)
{uint16_t maxN_power = 0;uint16_t max_power = 0;int i = 1; max_power = FFT_Value[FFT_INDEX(Get_SourceFreq(), sample_rate, FFT_POINT)];while(Get_SourceFreq()*(i+1) <= sample_rate/2){maxN_power += FFT_Value[FFT_INDEX(Get_SourceFreq()*(i+1), sample_rate, FFT_POINT)];i++;}return sqrtf(((float)maxN_power)/max_power);
}
最后,由于OLED显示仅128像素,只能将256点FFT的0~127归一化后作为数据显示(显示区域高90,宽128)。当对数形式显示时,将0值映射至-30dB,将最大值映射为0,由于算得的FFT数组已为幅度谱,因此只需 lg ( x ) \lg(x) lg(x)即可,乘-30为改符号为正,并映射至0~90。
void Generate_Spectrum(uint16_t *FFTValue, uint8_t *y, uint8_t log_or_linear)
{uint8_t max_index = Get_SpectrumMax(FFTValue, 0);uint8_t i;if(log_or_linear){for(i = 0; i < FFT_POINT / 2; i++){if(FFTValue[i] > 0){y[i] = (uint8_t)(-30*log10f((1.0*FFTValue[i]/FFTValue[max_index])));}else{y[i] = GRAPH_HEIGHT-1;}}}else{for(i = 0; i < FFT_POINT / 2; i++){y[i] = (GRAPH_HEIGHT-1) * (FFTValue[max_index] - FFTValue[i]) / FFTValue[max_index];}}
}
获取按键动作
按键相关代码参见keys.c/.h
对常规按键的处理参考SCOPE-F072–基于STM32F072的多功能掌中仪器中对按键的处理,通过设置TIM14每1ms产生Update中断,并对按键扫描,可以获取按键下边沿、上边沿、长按、短点击、长按后置高、长按时间等多个动作,当按键产生动作后,执行相应操作(如改变采样率、切换示波器/频谱等)。
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{ if(htim->Instance == TIM14){Key_Handle(); // 在Key_Handle()中进行按键动作的操作}
}
对于EC11旋转编码器的处理,按键部分参考前述即可,左右旋转(PB4、PA15)通过一侧IO作为输入时钟捕获,判断另一侧IO的电位即可,在此设置TIM2_CH1(对应PA15):
Parameters | Value |
---|---|
Prescaler | 63 |
Counter Period | 999 |
Input Capture Channel 1参数如下: | |
Parameters | Value |
– | – |
Polarity Selection | Falling Edge |
IC Selection | Direct |
Prescaler Dividion Ratio | No division |
Input Filter | 4 |
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{if(htim->Instance == TIM2){if(HAL_GPIO_ReadPin(KeyB_GPIO_Port, KeyB_Pin)){zoom_out = 0x00;zoom_in = 0x01;}else{ zoom_out = 0x01;zoom_in = 0x00;}}
}
OLED显示
关于OLED显示相关代码参见display.c/.h
、wave.c/.h
及OLED库
经典128*128 4-wire SPI OLED。SPI2参数设置如下:
将oled.c
中的部分代码修改:
void OLED_WR_Byte(u8 dat,u8 cmd)
{ //u8 i; if(cmd)OLED_DC_Set();else OLED_DC_Clr();HAL_SPI_Transmit(&hspi2, &dat, 1, 1000);OLED_DC_Set();
}
void OLED_Clear(void)
{u8 i, n;for(i = 0; i < 16; i++){for(n = 0; n < 128; n++){OLED_GRAM[n][i] = 0;//清除所有数据}}//OLED_Refresh();//更新显示
}
修改为硬件SPI写入,删去清屏函数后的刷新可使OLED屏幕显示不闪烁。
wave.c
主要负责获取的数据的处理,做线性映射以显示在屏幕上,还做调整Y轴显示范围等功能。
display.c
主要负责显示波形,显示文字、显示其他信息等。
系统顶层
系统相关代码参见user.c/.h
主要负责监视OLED的状态与按键的动作:
typedef enum
{Oscilloscope,Distortion
}System_State;
/* ------------------- */
System_State System = Oscilloscope;
uint8_t Page_Init = 1;
uint8_t zoom_in = 0x00; // left for zoom in, right for zooming out
uint8_t zoom_out = 0x00;
void System_Change_State(System_State State)
{System = State;Page_Init = 1;
}
void OLED_Handle(void)
{switch(System){case Oscilloscope:{if(Page_Init){Page_Init = 0;zoom_in = 0x00;zoom_out = 0x00;Oscilloscope_Init();}memset(FFT_Value, 0x0000, sizeof(FFT_Value));memset(ADC_Value, 0x0000, sizeof(ADC_Value));Source_Init();Sample_Init();Wave_View(ADC_Value, Get_SampleRate(), graph);break;}case Distortion:{if(Page_Init){Page_Init = 0;zoom_in = 0x00;zoom_out = 0x00;Distortion_Init();}Sample_Init();memcpy(FFT_Value, ADC_Value, sizeof(FFT_Value));if(ZeroFFT((int16_t*)FFT_Value, FFT_POINT)== 0){Spectrum_View((uint16_t*)FFT_Value, graph, Get_SampleRate());}break;}default:break;}
}
按键动作大致设置如下:
void Key_Handle(void)
{static Key_Type Key[3] = {0};Get_Key(Key);switch(System){case Oscilloscope:{if(Get_Rise(Key1) || Get_Long_Tri(Key1)){if(is_setting_source_freq())Inc_SourceFreq();elseInc_SourceAmp();}if(Get_Rise(Key2) || Get_Long_Tri(Key2)){if(is_setting_source_freq())Dec_SourceFreq();elseDec_SourceAmp();}if(Get_Long_Press(KeyP)) // 长按旋钮{toggle_display();System_Change_State(Distortion);}if(Get_Cont_Click(KeyP) == 2){toggle_scale();}if(Get_Rise(KeyP)) // 短按旋钮{toggle_source_setting();}if(zoom_in == 0x01){if(is_auto_scale()){Dec_SampleRate();}else{Inc_YScale();}zoom_in = 0x00;}else if(zoom_out == 0x01){if(is_auto_scale()){Inc_SampleRate();}else{Dec_YScale();}zoom_out = 0x00;}break;}case Distortion:{if(Get_Long_Press(KeyP)) // 长按旋钮{toggle_display();System_Change_State(Oscilloscope);}if(Get_Rise(KeyP)) // 短按旋钮{toggle_spectrum_yaxis();}if(zoom_in == 0x01){Inc_SampleRate();zoom_in = 0x00;}else if(zoom_out == 0x01){Dec_SampleRate();zoom_out = 0x00;}break;}default:break;}
}
在示波器状态下,按下板卡下方两个按钮,若目前是调整源频率/幅度,增大/减小频率/幅度;长按旋钮,切换至频谱/失真度曲线显示。
功能展示
OLED显示采样波形
示波器页面,中间横线显示直流电平所在处,左上角显示采样时间,中间显示目前所调整的为源频率/幅度,右上角标识当前页面。下方左侧显示信号峰值、直流偏置电压及目前Y轴显示范围;右侧显示源频率、源幅度及Y轴显示范围自动/手动调整。
长按旋钮,切换至频谱/失真度页面。
OLED显示频谱/失真度曲线
左上方显示横轴每格代表频率,随采样率而变化,右上角标识当前页面。下方左侧标识当前为线性/对数坐标显示,及THD;右侧为通过FFT计算的中心频率。
短按旋钮即可切换线性/对数坐标。
👉 项目演示视频参见:基于STM32G031的失真度测试仪
项目总结
- 实现了PWM+板上LPF电路生成频率在DC~20KHz的正弦波信号,频率可调,并且幅度可调,从10mV~500mV,但当幅度小时生成的正弦波幅度偏差较大,当生成直流时,计算FFT会卡死;
- 实现了256点ADC+DMA采样,将采样的波形及其信息显示在OLED上;
- 实现了256点FFT、THD的计算,显示了归一化幅值谱、对数坐标显示失真度曲线。
由于此前作者纯Keil与库函数开发,此次项目接触到了CubeMX与HAL库工具链,一键生成MDK工程雀食方便,工程项目的排布省时省力。HAL库在某些包装上也有其独特优势,今后用在F407的开发试试。
👉工程文件及代码:参见【2022寒假在家练】基于STM32G031的失真度测试仪
👉 CSDN:工程源代码下载
👉 Github-KafCoppelia/DistortionMeter