项目介绍
本项目基于电子森林的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 np
def sin_wave(point, num):
y = []
for i in range(0, point):
fz = num/2 * np.sin(np.pi/point*2*i) + num/2
y.append(fz)
return y
if __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;
__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
s
y
s
t
i
c
k
f_{SPWM}=\dfrac{f_{sine}\cdot N}{f_{systick}}
fSPWM?=fsystick?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();
}
}
对于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)
{
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;
}
}
}
修改为硬件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;
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();
else
Inc_SourceAmp();
}
if(Get_Rise(Key2) || Get_Long_Tri(Key2))
{
if(is_setting_source_freq())
Dec_SourceFreq();
else
Dec_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计算的中心频率。
短按旋钮即可切换线性/对数坐标。
👉 项目演示视频参见:A
项目总结
- 实现了PWM+板上LPF电路生成频率在DC~20KHz的正弦波信号,频率可调,并且幅度可调,从10mV~500mV,但当幅度小时生成的正弦波幅度偏差较大,当生成直流时,计算FFT会卡死;
- 实现了256点ADC+DMA采样,将采样的波形及其信息显示在OLED上;
- 实现了256点FFT、THD的计算,显示了归一化幅值谱、对数坐标显示失真度曲线。
由于此前作者纯Keil与库函数开发,此次项目接触到了CubeMX与HAL库工具链,一键生成MDK工程雀食方便,工程项目的排布省时省力。HAL库在某些包装上也有其独特优势,今后用在F407的开发试试。
👉工程文件及代码:参见A 👉 项目文件
|