1 项目需求
- 完成对板上音频信号的采集和波形显示,可以通过手机播放音乐或App产生音频信号的方式提供声音信号源,通过板上电路的放大、MCU中ADC的采集以后将波形显示在OLED屏幕上,可以通过板上按键的操作在两个方向(横轴 - 时间;纵轴 - 幅度)来扩展、压缩波形的显示,按键的功能可自行定义;
- 实现信号发生器的功能,能够产生2KHz以内的正弦波、三角波、方波三种常用波形,通过按键的操作能够实现频率可调、幅度可调,通过调整板上的R、C的值,可以最高生成200KHz的模拟信号;
- 能够通过Ain管脚测量外部模拟信号(0-3.3V,DC-200KHz),并能够对外部的周期性波形测量其周期和峰-峰值;
- 能够对采集到的信号进行FFT变换,并在屏幕上显示其基频及低次谐波(比如2、3、4、5次)的分量。
2 完成的功能及达到的性能
2.1 波形显示
显示波形时,按下L提高采样率,按下R降低采样率,采样率取值范围为1kHz、2.5kHz、5kHz、10kHz、25kHz、50kHz、100kHz、250kHz、500kHz、1MHz,通过改变采样率来实现横轴的缩放。
Y轴(幅度范围)默认为自动调整,即程序自动根据采样序列调整Y轴中心电压值和缩放范围,使波形完整显示在屏幕上。通过菜单可以改为手动模式,即手动调整Y轴中心电压值和Y轴缩放范围。
左下角显示波形参数,可以显示时间轴分度值、信号峰峰值、直流分量和频率。
正下方显示当前状态,包含输入通道、触发状态和前述的Y轴缩放方式(A:自动缩放,MO (Manual Offset):U/D按键调整Y轴中心电压值,MS (Manual Scale):U/D按键调整Y轴缩放范围。
按下OK键可以暂停波形刷新,再按可以继续刷新。
2.2 触发显示和触发菜单
程序默认为上升沿触发,触发电平为1.68V。显示波形且触发开启时,屏幕正下方显示当前触发边沿(上升沿、下降沿)和触发状态(箭头点亮为触发成功、背景点亮为触发失败)。
长按R键打开触发菜单,在触发菜单中可以开启/关闭触发,选择触发边沿,选择自动触发还是单次触发。
2.3 示波器菜单
长按OK键打开示波器菜单,示波器菜单共有4项,分别是:波形/频谱显示切换、Y轴缩放方式、波形参数切换、通道切换(麦克风与板上信号输入)。LRUD四个按键用来对上述四项功能进行切换。
2.4 频谱显示
通过菜单切换至频谱显示时,屏幕显示信号的频谱,显示频率范围为直流至采样频率的一半。同样按下L提高采样率,按下R降低采样率。左下角显示频率轴分度值。
2.5 信号输出
长按L键打开输出菜单,在输出菜单中,可以开启/关闭信号输出,增加/降低输出信号的频率(步长100Hz,上限2kHz)、峰峰值(步长0.1V,上限3.3V)和调整输出波形(正弦波、三角波、方波)。
3 实现思路
- ADC对模拟输入进行采样,采样由定时器触发,采样结果由DMA搬运;
- 将采样得到的ADC量化值映射到屏幕坐标点上,实现波形显示;
- 按下按键调整采样频率,实现波形在时间轴上的扩展与压缩;
- 对采样序列进行FFT变换,绘制频谱;
- 信号参数的显示,如峰峰值、直流分量、信号频率等;
- 输出PWM波并通过RC低通滤波实现方波、正弦波、三角波的生成,通过按键改变PWM波的频率与占空比,从而改变输出信号的频率和幅度。
4 实现过程
4.1 程序流程图
注:每个框图右下角名称为执行该功能的主要文件
4.2 ADC对数据进行采样
为了方便进行FFT计算,ADC共采集256个采样点。每次ADC转换由定时器1触发,触发频率最高为1MHz,即ADC采样率最高为1Msps。ADC的转换结果直接由DMA搬运至内存。
ADC转换开始函数(定义位置:sample.c,调用位置:main.c):
/**
* @brief Start a new sample sequence.
* @param[in] ADCValue Array to store incoming sample values.
* @retval None
*/
void start_sample(uint16_t *ADCValue)
{
HAL_Delay(1);
HAL_ADCEx_Calibration_Start(&hadc1);
HAL_ADC_Start_DMA(&hadc1, (uint32_t *)ADCValue, SAMPLE_POINTS);
}
256次转换结束后进入中断,置位结束标志位,进入后续的数据处理程序。
ADC转换结束中断回调函数(定义位置:adc.c):
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
if(hadc == &hadc1)
{
finish_sample();
}
}
4.3 采样结果的处理
得到256个采样的ADC量化值后,根据触发电平选择波形起始点,返回起始点在数组中的下标,显示从起始点开始的100个点。
波形触发部分代码(定义位置:wave.c,调用位置:app.c,其中total_points=256, GRAPH_WIDTH=101):
/**
* @brief Wave trigger.
* @param[in] ADCValue Array of sampled ADC values.
* @param[in] total_points Total sampled points.
* @retval Index of the trigger start point(>1). 0 means trigger off or failed.
*/
uint16_t trigger(uint16_t *ADCValue, uint16_t total_points)
{
uint16_t i;
uint16_t trigger_value = VOL2ADC(1.68);
if (!is_trigger_on())
return 0;
for (i = 1; i < total_points - GRAPH_WIDTH + 2; i++)
{
if (get_trigger_edge()) // falling edge
{
if (ADCValue[i-1] > trigger_value && ADCValue[i] <= trigger_value)
{
trigger_success();
if (is_trigger_single())
pause();
return i;
}
}
else
{
if (ADCValue[i-1] <= trigger_value && ADCValue[i] > trigger_value)
{
trigger_success();
if (is_trigger_single())
pause();
return i;
}
}
}
trigger_fail();
return 0;
}
取起始点后100个采样值使其显示在OLED屏幕上(一次性刷新)。为此需要将ADC量化值与OLED屏幕上的坐标进行线性映射。在自动模式(自动缩放y轴)中,程序自动找出量化值中的最大最小值,并使最大最小值也能不超出绘制范围以外,这样屏幕就可以显示完整的波形。
自动缩放y轴代码(定义位置:wave.c,调用位置:app.c):
/**
* @brief Automatically find the central/max/min voltage on y-axis.
* @param[in] ADCValue Array of sampled ADC values.
* @note The function calculates the min/max voltage of the sampled signal,
* then find a proper scale voltage and a central voltage on y-axis.
* @retval None
*/
void auto_scale(uint16_t *ADCValue)
{
uint16_t a_max_value, a_min_value, a_pp_value;
float exact_voltage, floor_voltage, ceil_voltage;
get_max_min_pp_value(ADCValue, &a_max_value, &a_min_value, &a_pp_value);
voltage_range_auto_select(ADC2VOL(a_pp_value/2));
exact_voltage = ADC2VOL(a_max_value + a_min_value) / 2;
floor_voltage = (uint8_t)(ADC2VOL((a_max_value + a_min_value)*5)) / 10.0; //keep one decimal
ceil_voltage = floor_voltage + 0.1;
// round center_voltage
volt_on_y_axis.center_voltage = ceil_voltage - exact_voltage < exact_voltage - floor_voltage ? ceil_voltage : floor_voltage;
volt_on_y_axis.max_voltage = volt_on_y_axis.center_voltage + v_scale_list[v_scale_index];
volt_on_y_axis.min_voltage = volt_on_y_axis.center_voltage - v_scale_list[v_scale_index];
}
坐标映射代码(定义位置:wave.c,调用位置:app.c):
/**
* @brief Generate y-coordinates of the wave.
* @param[in] ADCValue Array of sampled ADC values.
* @param[out] y Y-coordinate array of the wave.
* @note The function map ADCValues to OLED y coordinates.
* @retval None
*/
void generate_wave(uint16_t *ADCValue, uint8_t *y)
{
// Quantize y-axis min/max/central voltages to ADC values.
int16_t a_max_value = VOL2ADC(volt_on_y_axis.max_voltage);
int16_t a_min_value = VOL2ADC(volt_on_y_axis.min_voltage);
uint8_t i;
// Linearly map every ADC value to its coordinate.
for (i = 0; i < GRAPH_WIDTH - 1; i++)
{
if (ADCValue[i] <= a_max_value && ADCValue[i] >= a_min_value)
y[i] = (GRAPH_HEIGHT - 1) * (a_max_value - ADCValue[i]) / (a_max_value - a_min_value) + GRAPH_START_Y;
else if (ADCValue[i] > a_max_value)
y[i] = GRAPH_START_Y;
else if (ADCValue[i] < a_min_value)
y[i] = GRAPH_HEIGHT + GRAPH_START_Y - 1;
}
}
波形显示代码(定义位置:display.c,调用位置:app.c):
/**
* @brief Display wave on OLED.
* @param[in] y Y-coordinate array of the wave.
* @retval None
*/
void display_wave(const uint8_t *y)
{
uint8_t x;
for (x = GRAPH_START_X; x < GRAPH_WIDTH - 1; x++)
OLED_DrawLine(x, y[x-GRAPH_START_X], x + 1, y[x-GRAPH_START_X+1], 1);
OLED_DrawPoint(x, y[x-GRAPH_START_X], 1);
}
在手动模式中,可以手动调节y轴的缩放范围和y轴中心电压值,但此时波形不一定会完整显示。得到采样点坐标后,使用OLED的绘制直线函数,连接屏幕上各个离散的点,就可以得到信号的波形。
当需要显示频谱时,就需要对所有的ADC的量化值进行256点FFT变换,由于FFT变换结果关于中心点对称,且屏幕x方向分辨率为128点,所以保留FFT需要为0~127的结果,进行线性映射后显示在屏幕上。
FFT的代码定义在fftutil.c中,对变换结果的处理及显示分别定义在spectrum.c和display.c中。
4.4 信号发生器
板上有一个1Kohm的电阻和10nF的电容构成的低通滤波器,截止频率为1.6KHz。若在该输出端输出频率足够的PWM信号,则输出电压大小就和PWM的占空比成正比。通过改变PWM的占空比就可以调节输出电压波形。通过实验可知,当信号的每一个周期由500个PWM脉冲组成时,信号的纹波较小。
以正弦信号为例,在程序外,在电脑中生成一个正弦信号,并在一个周期中进行500次采样,根据电压和PWM占空比的正比关系可以计算出500个PWM脉冲的占空比。将其定义为长度为500的数组写入程序。程序中使能PWM的DMA通道,这样就可以在每个PWM脉冲结束后自动将数组中的元素载入定时器输出比较寄存器,从而改变占空比。低通滤波器再将STM32产生的PWM脉冲转变为模拟信号,即可重新生成正弦波。方波和三角波同理。
开启PWM和DMA代码(定义位置:source.c,调用位置:app.c,其中SIGNAL_LENGTH=500):
/**
* @brief Start signal output at Aux.
* @retval None
*/
void start_output(void)
{
HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_2, (uint32_t *)output_wave_value, SIGNAL_LENGTH);
}
信号的幅度调节可以直接对上述数组每个元素乘一个常数来实现;频率调节首先要调节定时器的自动重载值(ARR),改变PWM的频率。为保证幅度不变,数组中每个元素也要同比例缩放。
5 遇到的主要难题
5.1 中断与DMA
项目共有两处使用DMA,分别用于储存ADC采样结果和调整输出PWM定时器的自动重载值(ARR)。如果用中断处理数据而非DMA,则会产生以下问题:
若在ADC转换完成中断中读取转换结果,则在一次采样序列(256点)中,中断频率过于频繁,且由于中断耗时,无法得到很高的采样率,最高只能达到几十kHz。若使用DMA,则只需在整个采样序列结束后进入中断,不会对采样造成影响。
若使用PWM中断更新自动重载值,中断耗时会使PWM频率产生偏差,且会对OLED屏幕的SPI时序造成影响,导致屏幕无法正常显示。若使用DMA更新自动重载值,则不需要PWM中断,更新耗时相比于中断有很大改善。
综上所述,在频率较高或需要频繁更新数据的情况,中断会带来各种各样的问题,而DMA则可以高效完成任务。
5.2 RAM和Flash大小(FFT优化)
项目使用的FFT算法根据Adafruit ZeroFFT修改而来。该算法最高可支持4096点FFT,其旋转因子表、窗函数表和信号序列数组占用空间极大。而本项目使用的STM32G031G8只有64K的Flash和8K的RAM,资源极为有限,无法直接运行ZeroFFT。
为此需要对ZeroFFT的代码进行优化。该项目只需256点FFT,删去256点之外的部分,缩短查找表,能极大减小RAM和Flash占用。
具体的优化步骤:
- 将Adafruit_ZeroFFT.h中的宏定义ZERO_FFT_MAX改为512。(对应256点FFT)
- 删去fftutil.c中ZeroFFT函数所有其他点数的FFT代码,只保留256点FFT的代码。同样删去窗函数中256点以外的部分和窗函数查找表。
- 此时fftutil.c中只调用了arm_common_tables.c中armBitRevTable和twiddleCoefQ15两个查找表,删去其他所有数组。
- 在fftutil.c中所有调用armBitRevTable和twiddleCoefQ15查找表的代码下面添加printf,用PC运行FFT程序,打印调用的下标。
- 以twiddleCoefQ15数组为例,原长度为6144;对于256点FFT,只有其中384个值被调用。PC中编写一个临时程序,根据调用的下标,用printf打印一个新的长度为384的查找表替换掉原来的。另一个查找表同理。
- fftutil.c中部分变量代表查找表的步进值,查找表改变后这些步进值也要改变。
- 此时FFT的代码应该就可以在STM32G0上运行了~
此外,由于Flash和RAM的资源有限,在FFT之外的其他很多地方也需要对空间进行优化,比如删去oled不需要的字库等。
5.3 PWM输出频率
由于电容的充放电,由PWM经过低通滤波输出的信号会有锯齿,信号幅度较低时锯齿更为明显,并会造成波形显示的不稳定。开始时输出信号一个周期内有50个PWM脉冲,即PWM的频率是信号频率的50倍,当信号幅度较低时锯齿极为明显,对输出波形造成极大干扰。将一个周期内PWM脉冲数提升至500,锯齿密度变大,同时幅度减小,对输出信号的干扰也减小。但同时储存输出信号幅度信息的查找表也变大10倍,消耗了更多的空间。
6 未来的计划建议
该项目已经成功实现了简易示波器和信号发生器的功能,并达到了预期指标。然而通过更换硬件,还有许多可以提升与扩展的地方:
- 板上的OLED屏幕分辨率较低,无法显示信号细节与更多信息。可以使用分辨率更高的屏幕,或将波形信息直接发送给上位机,由上位机进行显示。
- 主控芯片STM32G031的资源有限。可以更换更好的主控芯片,来提高采样率,采样点数等从而实现更高的性能。
- 可以对输入信号进行衰减,从而增大输入信号的电压范围。
- 增加模拟输入的通道,并添加波形的数学运算功能,如波形之间的加减。
- 改变输出端的RC值,扩展输出信号频率范围。
不更换硬件可以提升与扩展的地方(懒得做的部分):
- 自动/手动调整触发电平。
- 改变输入信号耦合方式(直流/交流耦合)。
- 对输入信号进行数字滤波。
- 信号源实现更高的频率分辨率。