从零开始的DSP之旅-准备启程

从零开始的DSP之旅-准备启程

最近🐟打算学学DSP(Digital Signal Processing,数字信号处理),方便以后做项目的时候应用。(啊毕竟挖了那么多仪器的坑,想填哪个都得用一大堆DSP的知识…)

本科时云里雾里地混过了DSP考试,但其实没怎么学懂,现在我打算通过实践来重新认识数字信号处理技术。

1.材料&工具准备

  • STM32G474RET6 Nucleo板(当然,随便一个带adc/dac/fpu的stm32器件都是可以的)
  • 示波器&信号源&万用表(EE三件套)
  • STM32CubeIDE 1.8.0(我用的版本)
  • 一些杜邦线,面包板,外围元器件等等

工欲善其事,必先利其器。对于与现实世界相接轨的数字信号处理来说,最重要的当属ADC和DAC器件了。所以我们需要熟悉stm32的外设,至少得把ADC和DAC跑起来,同时把CMSIS-DSP库用起来~

2.STM32G4 ADC

在很早之前的文章[Bonjour STM32] No.8-demo 5.ADC-DMA采样中,我们已经了解了ADC与DMA配合工作的基本步骤,现在我们需要更进一步。

我们如果让ADC不间断地连续运行,那么它的采样率就取决于ADC时钟和ADC配置的保持时间等参数。如果我们想要让ADC以我们期望的一个 已知 的采样率去采集模拟信号,这时候就需要一些额外的工作了。

STM32G474RE Reference Manual 中,我们可以找到G474的ADC Block Diagram。由图可见,G474的ADC是个十分复杂且麻烦的玩意儿,但如果学会了如何驾驭它,你会不禁直呼真香的)

2.1.ADC的采样率&转换时间

G474的ADC的转换时序可在21.4.16小节中找到,总的来说,ADC完成一次完整的采样转换过程所需要的时间有如下几个因素决定:

  • ADC时钟频率(T_{ADC_CLK})
  • 所配置的ADC分辨率(量化bit数),精度越高越慢
  • 采样保持电路的 采样保持时间(单位为cycles)
  • SAR ADC (逐次逼近ADC)的转换速度

比如说,如果ADC的时钟频率为40MHz,那么周期为25ns,即对应一个cycle的时间;
现在设置ADC分辨率为12位,对应的ADC转换速度为12.5个cycle,设置采样保持时间为2.5个cycle,总共转换时间为:

T_{CONV} = T_{SAMPLE}+T_{SAR} = (2.5 + 12.5) cycles = 15*25ns=375ns 

也就是说,ADC完整地完成一次模数转换所需的时间是375ns。如果让ADC连续不断地进行转换,此时理论最高采样率:

f_s = {1\over 375ns}=2.666 MHz

如果我们配置ADC进行连续转换(Continuous conversion),并且设置DMA接收数据的话,那么ADC一经启动就会以这个采样率不断地进行转换。

2.2.固定vs可变采样率

显然,在一个数模转换系统中,固定采样率将会是一个十分鸡肋的致命弱点…
比如说,如果ADC的采样率固定为1MHz,然后我们在RAM中开辟了长度为1000的存储空间(buffer)用来接收ADC采样的数据,那么仅需1ms,这个buffer就会被ADC传来的数据填满。如果我们此时想要采集频率为50Hz的低频信号,这个buffer只能存下该信号的1/20个周期的数据。。。

这时有2种解决方法,第一种是加长存储buffer,但这会让内存爆炸,况且在MCU上我们并没有多少内存可用。第二种则是更为明智的做法——降低采样率。比如降低采样率到10kHz,现在要填满1000个点的buffer需要100ms,这段时间内我们可以采集5个周期的频率为50Hz的信号波形数据。通过 可变采样率 就可以在内存开销不变的情况下,极大拓展信号采集、分析的频段范围。

2.3.可变采样率的实现

在我们的G474上,从影响ADC采样率的途径入手,可变采样率有几种实现方式:

  1. 改变ADC时钟频率
  2. 降低ADC精度
  3. 改变采样保持时间
  4. 使用trigger触发ADC采样

但是你稍微琢磨一下就会发现,前3个方法都是没什么实际应用价值的…(虽然加长采样保持时间可以起到输入低通滤波的效果),唯一可行且灵活的方式是 使用trigger触发ADC采样。回到G474的ADC架构图中,我们可以在ADC下方找到触发的信号路径:

可以看出,32的ADC不仅可以用软件触发,也可以由硬件触发;而且触发源多种多样,可以选择单片机内部的外设信号,也可以选择使用外部IO输入的信号上升/下降沿,这为我们的设计带来了极大的灵活性。

那么可变采样率的实现思路就非常清晰了:我们现在需要一个灵活可变的时间/频率基准作为ADC的触发信号。说到单片机内的时间基准,熟悉单片机的同学第一时间肯定能想到 定时器(Timer) 的存在吧~ (关于定时器,可以康康CNPP的 STM32 Timer cookbook)

我们在这里就使用g474的timer8的 update event 作为ADC的触发源,也即ADC的 "采样时钟"。同时请注意,ADC的采样时钟与ADC时钟并不等同。之所以有这个区分是因为ADC架构的差异。在Sigma-Delta和SAR等架构的ADC中,ADC时钟是指整个ADC单元的工作时钟;而在标准高速ADC中,ADC器件只需要采样时钟。比如下图 AD9608 的工作时序:

在采样时钟的上升或下降沿,ADC对信号进行转换并输出一个数据点,采样时钟与数据点是一一对应的关系,采样时钟的频率即是ADC的采样率。

而在SAR和Sigma-Delta这类ADC中,供给ADC的时钟是其中的数字逻辑器件工作的时钟,并不是ADC Core的时钟,此时ADC时钟频率与ADC的采样率并不等同,但是存在定量关系。所以在这类ADC架构中,采样时钟的说法并不严谨,也许你现在明白我上面的采样时钟为什么打引号了:D

2.4.配置ADC为指定采样率

扯了这么多废话,let coding.
还是老规矩,新建一个G474RET6的工程,加载Nucleo板的默认配置,配置好时钟树(这里我配置cpu主频为160MHz,为了得到40MHz的采样时钟,同时也便于定时器分频)。然后我们来配置ADC:

打开ADC1的IN1,同步时钟4分频得到40MHz的ADC时钟,选择ADC转换外部触发源为 Timer 8 Trigger Out Event(TIM8的触发输出事件),指定该信号触发边沿为上升沿触发,同时设置Rank 1(也就是IN1)的采样保持时间为2.5Cycles(最短时间)。

然后在DMA Settings中Add DMA,按照图中参数配置,回到参数设置,开启DMA Continuous Requests,设置Overrun behaviour为覆写。

接下来我们配置定时器,打开TIM8的配置,选择Clock Source为内部时钟源(我忘了TIM8挂在哪个APB总线上了,反正都是160MHz 23333),PSC设置为4-1,Counter Period 20-1,这样可以将160MHz分频为2MHz。Trigger Output中,选择Trigger Event Selection TRGO为 update event(更新事件),这样就得到了我们的"采样时钟"。

然后简单讲讲代码的编写思路。请直接看下面的流程图吧~

生成代码,打开main.c,在指定的地方(cube生成的代码中含有 User Code Begin X 注释,根据我的代码片段对应main.c中的位置即可)添加如下代码:

这一段是定义采样点数和buffer,以及转换完成标记信号

/* Private user code ---------------------------------------------------------*/
/* USER CODE BEGIN 0 */
#include \
//  Sample Length
#define ADC_SAMPLE_LEN      200
//  Sample Buffer
uint16_t adc1_buff[ADC_SAMPLE_LEN];
//  Conversion complete flag
volatile uint8_t adc_conv_cplt_flag = 0;

/* USER CODE END 0 */

这些代码是ADC初始化、ADC开启DMA转换、开启TIM,以及while(1)中的数据输出处理;

  /* USER CODE BEGIN 2 */
  //    Calibration Start & Initialize ADC
  HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED);
  //    Delay 100ms
  HAL_Delay(100);
  //    Start ADC DMA Transfer
  HAL_ADC_Start_DMA(&hadc1, adc1_buff, ADC_SAMPLE_LEN);
  //    Start TIM8 (Sampling Clock Source)
  HAL_TIM_Base_Start(&htim8);

  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  while (1)
  {
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */

      //    If Conversion Complete
      if (adc_conv_cplt_flag == 1)
      {
          //    Clear cplt_flag
          adc_conv_cplt_flag = 0;
          //    print sampled data
          for(uint16_t i = 0; i < ADC_SAMPLE_LEN; i++)
          {
              printf("a=%d\r\n",adc1_buff[i]);
          }
          //    Restart next sample cycle
          HAL_ADC_Start_DMA(&hadc1, adc1_buff, ADC_SAMPLE_LEN);
          HAL_TIM_Base_Start(&htim8);
      }
      //    "sample-print" cycle period : 500ms
      HAL_Delay(500);
  }
  /* USER CODE END 3 */

这一段是DMA传输完成的中断回调函数(Callback function)

/* USER CODE BEGIN 4 */

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    //  Stop ADC DMA Transfer
    HAL_ADC_Stop_DMA(&hadc1);
    //  Stop TIM8(Sampling Clock)
    HAL_TIM_Base_Stop(&htim8);
    //  Set conversion complete flag
    adc_conv_cplt_flag = 1;
}
/* USER CODE END 4 */

哦别忘了,要在mcu上使用标准c库中的printf的话,需要重定向IO函数。打开usart.c文件,在指定位置(其实就是开头)添加:


/* USER CODE BEGIN 0 */
#include 

#define PUTCHAR_PROTOTYPE int __io_putchar(int ch)

PUTCHAR_PROTOTYPE
{
  /* Place your implementation of fputc here */
  /* e.g. write a character to the USART1 and Loop until the end of transmission */
  HAL_UART_Transmit(&hlpuart1, (uint8_t *)&ch, 1, 0xFFFF);

  return ch;
}
/* USER CODE END 0 */

注意我此处的 hlpuart1,如果你用了别的串口,需要自己改一下哦。
同时注意,这段代码适用于CubeIDE的编译器(arm-gcc),如果使用mdk(AC6编译器),重定向操作可能会有出入。最新版的我也忘了是啥了,自己查查吧233。

2.5.编译运行,检查结果

编译下载到单片机,打开信号源,输出一个幅度合理的200kHz正弦波信号,加载到ADC1的IN1脚(PA0)上,通过PC上的串口绘图工具观察回传的数据波形。

因为每2次采样之间间隔了约500ms,且信号源与单片机的时钟不同步,所以回传的数据之间会存在相位间断点,通过相位间断点可以判断单次回传的数据段落。

我们设置采样率为2MSa/s,采样buffer长度200点,输入信号频率200kHz,那么信号每个周期可以采集10个点,200点长度的buffer中应该有20个周期(cycle)的信号波形,照着图上一数,bingo。

3.多个ADC同步(Simultaneous)采样

我们已经有一个可控采样率的ADC,可用于很多单一信号的低速数据采集分析应用。但往往我们需要采集的信号不止一个,例如通信系统接收机的基带ADC需要采集I、Q两路信号,并且要求两路ADC执行严格 同步采样。同步采样意即2个或多个ADC的动作严格同步,就像复制粘贴出来的一样。

STM32G474RExx具有5个SAR型ADC,并且可以通过软件配置实现各种各样的协同工作,其中就有 主-从ADC同步采样模式。配置好32的基本项,然后打开ADC1的CH1,再打开ADC2的CH2,回到ADC1的配置页面中,ADC工作模式栏发生了变化——原先只有Independent Mode Only,现在多出了2个ADC配合工作的一些模式。在这里我们选择双ADC同步采样模式(如图),使能DMA access,并且在这里可以调整2个ADC同步采样的延迟周期,这里我们尽量希望它们没有延迟,选择最短的1 cycles,此处的cycle对应ADC的主时钟周期

此时ADC1作为主ADC,ADC2作为从ADC,构成了主-从协同同步采样ADC,主从采样的信号间存在1个cycle的延迟。接下来的配置都在主ADC(ADC1)的配置选项卡内完成,触发源选择使用TIM8,配置同第二节,DMA只添加ADC1的就可以了,但注意数据位宽需要改成Word。以及代码有部分需要微调。主要就是接收DMA数据的缓存buffer,需要修改成uint32_t类型,以及分离数据的地方有所不同

生成代码,这次我直接把所有需要写的代码放一块了:

/* USER CODE BEGIN PV */
#include 
//  Sample Length
#define ADC_SAMPLE_LEN      256

//  Sample Buffer
//  adc_sample for DMA access, n = SAMPLE_LEN
//  data storage: [adc2_s0(MSB)|adc1_s0(LSB), adc2_s1(MSB)|adc1_s1(LSB), ... , adc2_sn(MSB), adc1_sn(LSB)]
//  Change buffer size to 1x
uint32_t adc_sample[ADC_SAMPLE_LEN];
//  adc1/2_buff for data splitting
uint16_t adc1_buff[ADC_SAMPLE_LEN];
uint16_t adc2_buff[ADC_SAMPLE_LEN];
//  buffer for sum
uint16_t sum_result[ADC_SAMPLE_LEN];
//  Convert Complete flag
volatile uint8_t adc_conv_cplt_flag = 0;
/* USER CODE END PV */

/* USER CODE BEGIN 2 */

  //    Calibration Start & Initialize ADC
  HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED);
  HAL_ADCEx_Calibration_Start(&hadc2, ADC_SINGLE_ENDED);
  HAL_Delay(100);

  //    ADCEx function, ADC MultiMode Start
  HAL_ADCEx_MultiModeStart_DMA(&hadc1, adc_sample, ADC_SAMPLE_LEN);
  //    Start TIM8 (Sampling Clock Source)
  HAL_TIM_Base_Start(&htim8);

  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  while (1)
  {
      if (adc_conv_cplt_flag == 1)
      {
        adc_conv_cplt_flag = 0;

        for (uint16_t i = 0; i < ADC_SAMPLE_LEN; i++)
        {
            //  Split ADC Raw Data into 2 Arrays
            adc1_buff[i] = adc_sample[i] & 0xFFF;   //  LSB, right align
            adc2_buff[i] = (adc_sample[i] >> 16) & 0xFFF;   //  MSB, right align
            //  Make a sum, printf them
            sum_result[i] = adc1_buff[i] + adc2_buff[i];
            printf("a=%d,b=%d,c=%d\r\n",
                  adc1_buff[i], adc2_buff[i], sum_result[i]);
        }
        adc_conv_cplt_flag = 0;
        //  Restart next sample cycle
        HAL_ADCEx_MultiModeStart_DMA(&hadc1, adc_sample, ADC_SAMPLE_LEN);

        HAL_TIM_Base_Start(&htim8);
      }
      HAL_Delay(500);
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
  }

//  Conversion Complete Callback
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    //  ADC MultiMode Stop
    HAL_ADCEx_MultiModeStop_DMA(&hadc1);
    //  Stop TIM8(Sampling Clock)
    HAL_TIM_Base_Stop(&htim8);
    //  Set conversion complete flag
    adc_conv_cplt_flag = 1;
}

啊LPUART我就没写了,参考第二节。
编译上传,给ADC1的CH1和ADC2的CH2加上一个20kHz、正确直流偏置的正弦信号,然后打开串口助手观察数据:

从数据来看,2个ADC的工作状态基本上完美同步。个别采样点有1个LSB的误差是由于ADC的非线性、噪声等非理想因素引起的,可以不必在意。

然后再看绘图数据,2条数据的迹线几乎重合在一起(下方蓝色阴影),无法分辨,而它们的加和则刚好是蓝色曲线的2倍高(上方紫色阴影)。

顺便把DAC也用起来

在路上了!

CMSIS-DSP库

Reference

发表回复

这篇文章有一个评论

  1. 第 加载中...页

    看一半发现咕咕咕了?(手动滑稽