一、项目介绍
本项目使用STM32G031作为主控芯片,使用STM32CubeIDE作为开发环境,意在通过完成本项目,了解示波器的基本原理,学习ADC的几种不同用法,熟练使用CubeIDE进行开发。实现了最大660kSa/s 的采样率,以及368位的最大存储深度。
本项目开发原则是尽可能使用片上外设,减少软件复用,尽可能的提高系统运行性能以及示波器性能。但是由于在项目初期,对于示波器的实现方式并不完全了解,因此在对软件架构规划的时候没有规划完善,虽然最终实现了开发原则,基本完成了任务,但最终导致代码可读性较差,不适宜大家直接移植,但是本项目的一些思路还是可以被借鉴参考的。同时由于本人暂时没有数字信号处理的相关知识,本项目的频谱分析也不是十分完善。
本文更加面向示波器制作的初学者,介绍了一些从零开始实现示波器遇到的问题,以及解决方法,详细分析ADC各种方案的优劣性。下面的分析结果均为测试个人测试得出,欢迎同学们批评指正。
二、设计思路
通过运放电路对输入信号进行捕获,使用低通滤波电路搭配PWM,可以控制输出给运放正向输入端指定大小的电压,使得ADC可以采样到负值电压。使用继电器,更改不同电阻的接入,从而更改输入增益。最后ADC进行电压采样,根据所选通道以及电压增益自动计算合适的电压显示范围,最后将其显示显示到OLED屏幕上。
软件架构如下,按键滤波任务和中断函数的编码器共同控制示波器任务。
主程序流程图
单次波形计算流程图
DMA中断回调流程图
三、简单硬件介绍
- STM32G031 主控芯片
- 128*128 OLED屏幕
- 两路独立运放采样电路
- 一路任意信号发生器
四、 效果展示
方波显示
三角波显示
频谱显示
四、主要实现方法及遇到的困难
- 光电编码器读取
- 实现原因
根据本项目开发主旨,应当尽可能使用片上外设作为驱动。作为编码器,STM32有直接的编码器模式可以调用,由于硬件设计没有将A,B相接入对应的定时器引脚,因此无法直接使用该功能。但是可以通过定时器的输入捕获功能,对A相的上升沿和下降沿的进行触发。触发后利用中断函数对B相进行判断,最终实现对旋转方向的判断。
这样做对比外部中断的优点在于,定时器的输入捕获带有硬件级的滤波,比使用外部中断进行上升沿触发要更加鲁棒。外部中断虽然可以同时被上升沿或者下降沿触发,但是触发时并不可以直接知道是上升沿或是下降沿。需要使用ReadPin对引脚电平进行再次读取。但是定时器可以使用两个通道,对于同一引脚分别捕获上升沿或下降沿,这样一个通道只捕获上升沿,一个通道只捕获下降沿,可以大大提高代码运行效率。
- 具体实现
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
static uint8_t clock_state=0;
static uint8_t counter_clock_state=0;
if(htim==&htim2)
{
if(htim->Channel==HAL_TIM_ACTIVE_CHANNEL_1)
{
if(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_4))//Rising edge
{
if(counter_clock_state)
{
counter_clockwise_event();
counter_clock_state=0;
}
}
else
{
if(clock_state)
{
clockwise_event();
clock_state=0;
}
}
}
if(htim->Channel==HAL_TIM_ACTIVE_CHANNEL_2)
{
if(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_4))//Falling edge
{
clock_state=1;
}
else
{
counter_clock_state=1;
}
}
}
}
定时器配置参见工程文件TIM2定时器
测试发现,当朝一个方向连续转动编码器,B相电平读取可能会和实际转动方向不一致(大概4-5次发生会发生一次),为了解决该问题,我将一次上升沿读取数据和一次下降沿读取数据结合起来,若两次数据读取一致,则触发相应事件,若不一致则不触发。同时一次上升沿和一次下降沿也正好为该编码器旋转一次最小分度值所产生的信号。
2. ADC+DMA双缓冲区电压读取
- 需求分析
ADC的使用是该项目迭代次数最多的模块,经过了相当多次的迭代,实现了采样频率可控的ADC+DMA双缓冲区。
示波器有三个主要参数,采样率,存储深度,带宽,其中采样率和存储深度反应在代码中就是ADC的采样频率和DMA数组的大小。由于存储深度有限,对应低频信号如果依然保持一个很快的采样率,会造成波形捕获不完整,此时需要降低示波器的采样频率。同时由于需要对于波形进行频谱分析,测得波形频率,因此采样率的大小需要足够的精确。(精确,可调)
电子示波器波形并不是从左到右依次显示的,而是将一段时间内捕捉到的所有电压值作为一帧显示,下一帧则是下一段时间内捕捉到的电压值(如上图)。这存储这一帧数据的的空间就是存储空间,因此存储空间内的数据在计算完成之前,不可被下一次数据覆盖,否则将造成数据错位。如下图,该数据次数据在上一次处理完成之前,便被下一次数据覆盖了(如下图)。同样,若随意在一帧里停止示波器采样,同样会造成示波器信号错位失真。(数据加锁)
综上,示波器采样需要做到以下几点:
1)输出波形到屏幕之前,数组内容不可被更改
2)采样过程中不可随意停顿
3)尽可能节省内存空间,减少运算量
- 第一次迭代
使用ADC+DMA循环模式,单通道采样实现了2.2MHz的采样率。将ADC,DMA都配置为循环模式,只在代码启动一次,即可以最大速度循环进行采样。虽然基本达到了该芯片的最大理论数据,但是该方法的采样速度不可控,只为2.2M左右,且不可调,仅仅满足了上面四点要求的第三,四点,舍弃。
- 第二次迭代
ADC,DMA为单次模式,使用定时器中断来开启ADC。采样率依旧不可控,这个方法只能控制开启ADC的频率,但是开启后,采样的速度依旧为2.2M,且性能消耗巨大,舍弃。
- 第三次迭代
ADC为单次模式,DMA为循环模式,使用定时器事件更新来触发单次采样。实现了采样率的精确可控,但是由于DMA完成一次捕获传输之后,会从头继续传输新的数据,造成数据的错位。因此改方法需要进行改进。
接下来的处理方式就和有些同学不一样了,有些同学可能在两次DMA回调函数触发后,将数据拷贝到另一个数组里进行处理,或者直接关闭DMA,等到处理结束再打开。但是这样会造成几个问题。
1)需要开两份内存空间,特别占用内存。
2)既然已经使用DMA来进行数据拷贝了,还仍然需要再次把DMA数组里的数据拷出来,实属多次一举,这样不如直接CPU把外设原始数据进行拷贝,还能省掉一个DMA数组。
3)复制数据是单线程操作的,虽然这样数据可以不用加锁,但是却相当于大大减缓了数据处理速度,严重影响示波器性能。
为了解决以上问题,使用双缓冲区来对数据进行保存
- 第四次迭代
保持ADC,DMA配置不变,使用双缓冲区的思路进行数据处理。将DMA数组设置为一个共有两行的二维数组,在开启DMA的时候,初始化总的内存空间即二维数组全部的空间。这样若触发DMA半传输中断,即为第一行数组存储完毕,触发全传输中断即为第二行数组存储完毕。通过这两个中断来不断改变主循环中处理的目标数组。
这种方式完美的解决的上述的四个问题,但是将带来一个问题,便是若在一帧的数据处理过程中,发生了一次数据中断,更改的目标数组,这导致这一帧事实上使用了两帧的波形。这同样会导致失真。
- 最终迭代,加锁
为了保证示波器图像连续,防止数据混用。我们只能在两帧数据之间停止采样,不能在一帧数据中直接停止采样,这样同样会导致数据失真。
因此,我们引入两个标志位,一个是 计算标志位 ,一个是 停止标志位。在进行波形计算的时候,将计算标志位置1,计算结束后,再将标志位置0。同时在中断函数内添加对该标志位的判断,若计算标志位为1,则关闭定时器时基,停止采样同时将停止标志位置0,防止直接切换数组,导致数据数据混用。
计算结束后,若停止标志位为1(或者查看htim1结构体),表示采样已经停止,则再次开启采样,切换数组。相当于用两个变量对数据加了个锁,保证数据处理的固定。
mode_set.calc_flag=1;
cal_show_point();
mode_set.calc_flag=0;
if(mode_set.stop_flag)
{
mode_set.adc_buffer_flag=(mode_set.adc_buffer_flag+1)%2;
mode_set.stop_flag=0;
}
if(htim1.State==HAL_TIM_STATE_READY)
{
HAL_TIM_Base_Start(&htim1);
}
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
if( mode_set.calc_flag)
{
HAL_TIM_Base_Stop(&htim1);
mode_set.stop_flag=1;
}
else
{
mode_set.adc_buffer_flag=0;
}
}
void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc)
{
if( mode_set.calc_flag)
{
HAL_TIM_Base_Stop(&htim1);
mode_set.stop_flag=1;
}
else
{
mode_set.adc_buffer_flag=1;
}
}
事实上,一个制作精良的示波器应该保持连续采样或者实现动态采样,不应当在采样过程中出现过长时间间断,因为这会导致示波器漏过关键点波形点。但是由于该芯片flash,ram过小且主频低,无法使用FreeRTOS实时操作系统,因此该芯片无法来的及处理所有采样过的ADC数据。这便一定会导致漏过关键点,因此我前面停止ADC采样的操作也就无可厚非了,因为本身就会遗漏,采样但无法及时处理和不采样是没有区别的。
3. OLED屏幕的局部渲染
有了上次口袋游戏机的经验,这次对于屏幕刷新格外的重视,从开始的软件架构就是为局部渲染准备的。之所以叫做局部渲染而不是局部刷新,因为局部刷新应该只发送更改过的显存数据,但是这个刷新函数每次只能发送全部显存。所以应该叫做局部渲染。
- 编写局部清除函数
首先编写OLED_DrawPoint反向函数,OLED_ClearPoint,将对应显存赋值为0。
依次编写OLED_DrawLine等函数的反向清楚函数,思路是把OLED_DrawPoint替换为OLED_ClearPoint即可。
- 波形的刷新
使用一个数组记录波形位置数据,再计算新的位置数据之前,先利用同样的算法,擦除上一次的数据。这样便可使用一个数组同时清楚上一次数据,并保存下一次数据,做到数组复用,节省内存。
- UI刷新
使用一个标志位记录UI是否发改变,如没有发生改变,则不渲染。
五、未来的计划或建议
虽然尽可能的减少的CPU的运算量,但是由于芯片本身性能原因,再加上代码框架设计的问题,现在运行起来的效果确实不尽人意,也很难再加上新功能了。
若要继续再次基础上提升,接下来可以改进频谱分析,对信号实现动态采样。对底层代码实现封装,提高代码可维护性。高采样率情况下还是有概率出现信号错位,目前原因不明,需要使用波形发生器进行严谨测试。
接下来其实可以将该板卡完全变成电压采样下位机,将采样的到的数据全部使用串口上传到PC上位机。这样存储空间基本没有限制,可以使用2.2M的理论采样速度对信号不断进行采样,对信号的频谱分析精度也会有很大的提高。