大概6-7年前,在网上看到过一篇用STM32F1的DMA控制GPIO输出高速数字波形的帖子。觉得很有意思,就自己试了试:控制GPIO输出波形翻转的速度最高只能达到3-4MHz,且容易受到STM32F1的APB2总线其他设备读写的影响,输出的方波不稳定。由于问题较多,对高速实时性提升不大,感觉基本不实用,就没有再进一步研究。
前几天在研究STM32F4和STM32F1的区别时,发现STM32F4进行了两项升级:1、把GPIO连接从APB2总线改到AHB总线上,极大的改善了GPIO输入、输出的实时性和时序控制能力。2、增加DMA stream的概念(参见拙作https://www.cnblogs.com/helesheng/p/18167026),使得DMA传输请求的来源更明确。
DMA控制器的上述升级,使得STM32F1上比较"鸡肋"的DMA高速并行同步传输能力得到了较大提升,具备了一定的实用性。我尝试用STM32F407VE的DMA2在TIM1的触发配合下,实现了对并行接口的流水线型ADC的控制和读取。理论读取速度可达40MSPS以上,实测读取速度可超过AD9200E的理论上限------20MSPS。设计思路、程序和电路如下,供大家参考和指正。
以下原创内容欢迎网友转载,但请注明出处:https://www.cnblogs.com/helesheng
一、DMA控制高速同步并行数据传输的原理分析
本文基于DMA的GPIO高速并行读写程序,主要利用了STM32F4系列DMA的以下关键特性:
1)STM32的DMA传输可分为外设和内存之间,以及内存之间两种传输模式。这两种模式的最重要区别是:外设和内存之前的DMA传输必须由其他外部请求信号触发;而内存和内存之间的DMA传输则不会等待其他触发,它会在上一次传输进行完之后自动进行下一次传输。 在STM32的嵌入式系统中,除去少数只追求传输带宽的纯数据传输任务以外,大部分与硬件相关的DMA传输,还要要求在恰当的时刻完成传输。以本文要完成的高速A/D数据读取为例,读写数据的时刻必须收到采样间隔的严格控制。也就要求由定时器TIM来实现采样间隔的定时,即由TIM产生DMA传输的请求信号,而数据则通过DMA在GPIO数据寄存器和内存之间进行传输。
2)外设和内存之间的DMA传输的请求信号,可以是本次DMA传输的源或目的的外设,也可以是其他外设。**STM32F4的DMA传输请求信号由DMA的流(stream)和请求信号(或称通道channel)共同决定;但传输源和目的外设种类却由源和目的地址决定。**二者不能混为一谈。
3)STM32F4的两个DMA控制器的两个端口被指定为特定的数据源。具体连接关系如下图所示。以GPIO为例,STM32F4把它连接到AHB1总线上,由下图可知连接到AHB1外设的只有蓝色和黑色两种颜色的线,也就意味着,左侧的两个DMA控制器中只有连接了蓝色和黑色的DMA2可以实现与GPIO之间的DMA传输。
图1 STM32F4系列两个DMA控制器端口(源和目标)连接的外设/存储器种类
通过查询STM32F4的数据手册中的DMA请求信号表可知,可用于请求DMA2的定时器只有TIM1和TIM8两个高级定时器。我选择了TIM1的更新事件TIM1_UP来请求DMA传输。
图2 STMF4系列DMA2控制器的外设传输请求表
另外,控制高速ADC还要求单次DMA传输耗时要小于采样间隔,而STM32F4把GPIO连接到AHB1总线的意义也就在于此------相比之前将GPIO连接到APB2总线的STM32F1系列,STM32F4将能够更快速的对GPIO进行读写 ,从而提高与所控制ADC的数据读取速率。
最后,流水线型ADC还需要一个采样同步时钟;由于数据读取也是在该时钟的同步下进行,自然只能由TIM1时基部分同时产生该时钟。一种合理的解决办法是用TIM1的输出比较(OC)功能电路来产生。这也意味着该时钟只能由TIM1的某个通道(CHx)产生,从而只能在某些管脚上输出,这一点必须在硬件设计时加以注意。
二、高速并行接口ADC读取的程序和电路设计
1、硬件设计
下图是我采用的ADI公司的标称转换速率为20MSPS的流水线型A/D转换器------AD9200E(10bits分辨率)的工作时序图。
图3 AD9200的读取时序
可以发现,数据的更新发生在上升沿后25ns左右。如果输出比较OC电路采用时基计数的前段输出高电平,比较翻转的后输出低电平的模式,就会使得输出PWM信号的上升沿发生在TIM1更新时。而如前所述STM32F4的DMA2数据传输则发生在TIM1更新事件后,这就有由于高速数字电路的竞争与冒险造成读取时序不收敛。
我曾在某论坛看到过有人对AD9226做类似尝试,仅在16MSPS以上时就出现采样点读取错误的问题(https://blog.csdn.net/cusichidouren/article/details/126002742),我猜测就是由于这个原因。合理的解决方案其实也不复杂:对OC电路输出的PWM信号反相,使其在下降沿时触发DMA2传输请求。幸运的是STM32的OC输出支持负逻辑的PWM输出,不需要附加进一步的门电路。具体配置代码请参见软件设计部分。
具体GPIO选择方面,我用了PE0~16号端口来实现对AD9200的控制和读取。其中,PE口中PE11管脚可以配置为TIM1的通道2(CH2),可以作为AD9200的转换时钟(INPUT CLOCK)。而AD9200的10根数据线则用PE0~9负责读取,低位对齐的做法也有利于后续的数据读取和整理。AD9200的钳位控制(CLAMP)、溢出指示(OTR)、低功耗待机(STBY)等管脚则连接到PE口的其他管脚。原理图太简单,这里就不贴出来了,放一张实物图。注意:AD9200的模拟驱动应使用一个高压摆率的宽带运放,我使用了低成本的AD8052。
图4 实验系统实物图
2、软件设计
正如本文前面"原理分析"介绍的,DMA传输的通道、流、数据源/目标、传输请求信号如下图所示。
图5 DMA工作原理示意图
其中,DMA控制器的代码如下所示:
DMA_InitTypeDef DMA_InitStructure;
RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA2,ENABLE);//DMA2时钟使能
DMA_DeInit(DMA2_Stream5); //如果用TIM1更新触发,则使用DMA2_S5_CH6
while (DMA_GetCmdStatus(DMA2_Stream5) != DISABLE){}//等待DMA可配置
/* 配置 DMA Stream */
DMA_InitStructure.DMA_Channel = DMA_Channel_6; //通道选择为6通道
DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)(&(GPIOE->IDR));
DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)DST_DATA;
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralToMemory;//DMA_DIR_MemoryToMemory;//存储器到外设模式
DMA_InitStructure.DMA_BufferSize = SAMPLE_LEN;//数据传输量
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable;
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;//外设数据长度:16位
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;//存储器数据长度:16位
DMA_InitStructure.DMA_Mode = DMA_Mode_Circular;// 使用循环模式
DMA_InitStructure.DMA_Priority = DMA_Priority_VeryHigh;//高优先级
DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable;
DMA_InitStructure.DMA_FIFOThreshold = DMA_FIFOThreshold_Full;
DMA_InitStructure.DMA_MemoryBurst = DMA_MemoryBurst_Single;//存储器突发单次传输
DMA_InitStructure.DMA_PeripheralBurst = DMA_PeripheralBurst_Single;//外设突发单次传输
DMA_Init(DMA2_Stream5, &DMA_InitStructure);////如果用TIM1更新触发,则使用DMA2_S5_CH6
//开启DMA中断
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel=DMA2_Stream5_IRQn; //
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority=0x00; //抢占优先级1
NVIC_InitStructure.NVIC_IRQChannelSubPriority=0x03; //子优先级3
NVIC_InitStructure.NVIC_IRQChannelCmd=ENABLE;
NVIC_Init(&NVIC_InitStructure);
DMA_ITConfig(DMA2_Stream5,DMA_IT_TC,ENABLE); //
其中DMA2_Stream5的Channel_6是TIM1更新事件可以请求的DMA流,DST_DATA则是指向DMA缓存转换结果数据的片上SRAM区域的指针。DMA中断则用于在中断服务程序中读取转换结果(当然本程序对读取效率没有要求,因此也没有使用该中断服务程序功能)。
TIM1则需要一方面向外产生AD9200的转换工作时钟,另一方面用自动重装的更新信号触发DMA2的数据传输(如图5所示),因此TIM1的配置代码如下。
1 TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStructure;
2 TIM_OCInitTypeDef TIM_OCInitStructure;
3 RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1,ENABLE); ///使能TIM1时钟
4 TIM_DeInit(TIM1);
5 TIM_TimeBaseInitStructure.TIM_Period = 8-1; //自动重装载值
6 TIM_TimeBaseInitStructure.TIM_Prescaler= 1-1; //定时器分频
7 TIM_TimeBaseInitStructure.TIM_CounterMode=TIM_CounterMode_Up; //向上计数模式
8 TIM_TimeBaseInitStructure.TIM_ClockDivision=TIM_CKD_DIV1;
9 TIM_TimeBaseInit(TIM1,&TIM_TimeBaseInitStructure);//初始化TIM1时基
10 ////初始化TMR1的PWM输出,作为同步时钟/////
11 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1;
12 TIM_OCInitStructure.TIM_OutputState =TIM_OutputState_Enable;// TIM_OutputState_Enable;//正向输出使能
13 TIM_OCInitStructure.TIM_OutputNState = TIM_OutputNState_Disable;//反向输出禁止
14 //输出极性:TIM输出比较极性低,保证在下降沿之后,读取或写出数据
15 TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High;
16 TIM_OCInitStructure.TIM_Pulse = 0;
17 TIM_OC2Init(TIM1, &TIM_OCInitStructure); //根据T指定的参数初始化外设TIM9 OC2
18 TIM_OC2PreloadConfig(TIM1, TIM_OCPreload_Enable); //使能TIM1在CCR2上的预装载寄存器
19 TIM_CCxCmd(TIM1, TIM_Channel_2, TIM_CCx_Enable);//禁止TIM1-CH2 输出 需要单独使用 TIM1-CH2 时 此处因设置为 禁止TIM1-CH2N 输出
20 TIM_ARRPreloadConfig(TIM1,ENABLE);//ARPE使能
21 TIM_Cmd(TIM1, ENABLE); //使能TIM1
22 TIM_CtrlPWMOutputs(TIM1, ENABLE);
23 TIM_SetCompare2(TIM1, 4);
24 //选择DMA触发信号
25 TIM_DMACmd(TIM1, TIM_DMA_Update, ENABLE);//如果用TIM1更新触发,则使用DMA2_S5_CH6
其中,自动重装值8-1决定了AD9200的转换时钟为168/8=21MHz,这略微超过了AD9200手册规定的上线20MHz。主要原因是高速ADC比较贵,我手头有的只有AD9200,希望测试到它的工作极限。实测效果显示AD9200在21MSPS转换速度下还是能稳定工作点,如果用本方案做实际项目,还是要注意这个问题。
输出比较OC寄存器的值被设置为4,以保证输出的AD9200转换时钟占空比为1:1。
相关管脚的配置代码如下:
1 GPIO_InitTypeDef GPIO_InitStructure;
2 RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE);//使能GPIOB时钟
3 RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE);//使能GPIOA时钟
4 RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOE, ENABLE);//使能GPIOE时钟
5 RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOC, ENABLE);//使能GPIOC时钟
6 //PWM管脚输出ADC工作时钟
7 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11; //GPIOE11
8 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF;//复用功能
9 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; //速度100MHz
10 GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; //推挽复用输出
11 GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL;//上拉
12 GPIO_Init(GPIOE,&GPIO_InitStructure);
13 GPIO_PinAFConfig(GPIOE,GPIO_PinSource11,GPIO_AF_TIM1); //GPIOE11复用位定时器1
14 //AD9200的数字输入,通过GPIO来读取
15 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_2|GPIO_Pin_3|GPIO_Pin_4|GPIO_Pin_5|GPIO_Pin_6|GPIO_Pin_7|GPIO_Pin_8|GPIO_Pin_9;
16 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN;//普通输入模式
17 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;//100MHz
18 GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL;//浮空输入
19 GPIO_Init(GPIOE, &GPIO_InitStructure);//初始化GPIO
20 ///PE15作为AD9200待机模式控制端,高电平进入待机模式
21 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_15;
22 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;//普通输出模式
23 GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;//推挽输出
24 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;//100MHz
25 GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;//上拉
26 GPIO_Init(GPIOE, &GPIO_InitStructure);//初始化GPIO
27 GPIO_ResetBits(GPIOE,GPIO_Pin_15);//低电平输出ad9200进入正常工作模式
28 //led管脚输出
29 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_9;//LED0和LED1对应IO口
30 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT;//普通输出模式
31 GPIO_InitStructure.GPIO_OType = GPIO_OType_PP;//推挽输出
32 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;//100MHz
33 GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;//上拉
34 GPIO_Init(GPIOB, &GPIO_InitStructure);//初始化GPIO
35 GPIO_ResetBits(GPIOB,GPIO_Pin_8 | GPIO_Pin_9);//
36 //按键输入管脚
37 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_3|GPIO_Pin_4|GPIO_Pin_5;
38 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN;//普通输入模式
39 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz;//100MHz
40 GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP;//上拉
41 GPIO_Init(GPIOC, &GPIO_InitStructure);//初始化GPIO
其中最关键的是用于读取AD9200转换结果的PE0-9,共10个管脚,它们被配置为普通浮空输入模式,内部的GPIO数据寄存器IDR根据外部输入更新的速率就是AHB1总线的工作时钟84MHz。这也限制了使用这种基于DMA和GPIO读取并行数据的方法的带宽上限。
PE11复用功能中有作为TIM1CH2的功能,将其复用给TIM1:GPIO_PinAFConfig(GPIOE,GPIO_PinSource11,GPIO_AF_TIM1);
主程序的功能时等待按键,并在按键后将DMA的目标SRAM中的内容发送给PC显示,其代码如下所示:
1 extern unsigned short DST_DATA[SAMPLE_LEN];
2 int main(void)
3 {
4 unsigned short i;
5 delay_init(168); //初始化延时函数
6 Pin_Init(); //初始化LED端口
7 uart_init(115200);//串口初始化配置
8 DMA_Config();//初始化DMA控制器
9 USART_GetFlagStatus(USART1, USART_FLAG_TC); //这句可以防止第一个字节丢失
10 while(1)
11 {
12 if(KEYD == 0)
13 {
14 delay_ms(20);
15 if(KEYD == 0)
16 {
17 DMA_Cmd(DMA2_Stream5, ENABLE); //启动DMA传输
18 delay_ms(5);
19 for(i = 0 ; i < SAMPLE_LEN ; i++)
20 {
21 f2c.float_data = (DST_DATA[i] & 0x03ff) * 2.00 / 1024 ;
22 USART1_Putc(f2c.char_data[0]);
23 USART1_Putc(f2c.char_data[1]);
24 USART1_Putc(f2c.char_data[2]);
25 USART1_Putc(f2c.char_data[3]);
26 //固定内容的帧尾输出
27 USART1_Putc(0x00);
28 USART1_Putc(0x00);
29 USART1_Putc(0x80);
30 USART1_Putc(0x7F);
31 }
32 LED0 = 0;//灭灯表示数据传输完成
33 }
34 while(KEYD == 0);//等待到按键释放
35 delay_ms(10);
36 }
37 delay_ms(1);
38 }
39 }
为了方便的查看DMA读取的数据是否正确,我将数据用UART口发送到PC端开源的VOFA+软件上,通行的VOFA+数据格式为JustFloat。因此首先把数据从10位的二进制数转换为浮点数后,将IEEE754格式的单精度浮点数在分解为4个字节后由函数USART1_Putc();发出。浮点数分解为四个字节的方式是采用联合体(union)f2c来实现。f2c的定义如下:
1 union float2char//浮点数向可发送的字节转换的结构体
2 {
3 unsigned char char_data[4];
4 float float_data;
5 }f2c;
3、遇到的几个"坑"
尽管STM32F4是非常成熟的MCU产品线,但本文所述的"基于DMA的高速并行GPIO读写"并不是常见的功能,因此在调试过程中我还是遇到并克服了一些问题。个人觉得是芯片本身以及标准外设库的一些小问题造成的,但也可能是由于我才疏学浅、考虑不周的原因。罗列与此,供大家参考和指正。
1)官方提供的标准外设库高速外部晶振频率不匹配问题
我使用了ST官方提供的标准外设库作为开发平台,其中配置的高速外部晶振HSE的频率(HSE_VALUE)为25MHz。而我实际使用的晶振为8MHz,这除了导致UART通信的波特率不准之外,更重要的是还会导致TIM1输出比较OC电路输出的时钟频率不对。在固件库stm32f4xx.h中搜索宏"HSE_VALUE",将其改为8000000即可解决问题。
2)PA8管脚复用为TIM1_CH1输出比较功能时无输出的问题
我最初进行硬件设计时,曾想用管脚PA8复用的TIM1_CH1功能输出AD9200所需的转换时钟。但折腾了很长时间都无法让PA8管脚输出所需时钟(PWM)信号,在网上搜索后发现问题是STM32芯片的一个"顽疾"(www.openedv.com/posts/list/49738.htm)------只要使能复用在PA8、PA8、PA10等管脚上的USART1功能,就会导致PA8上的TIM1_CH1无法输出PWM信号。修补这个BUG也不困难:我改成使用TIM1_CH2(在PE11管脚上)输出PWM波,就很好的解决了这个问题。
三、测试结果
下图所示的是VOFA+软件显示的采集信号
图6 AD9200采集的信号波形(采样率为21MS,输入正弦信号为1MHz,采样长度为512点)
相关问题分析如下:
四、DMA控制高速并行ADC/DAC的弊端和问题
1、采样触发信号问题及其解决办法****
1) 用STM32这样的MCU代替FPGA来控制高速ADC,最大的问题在于MCU软件的实时性远远赶不上硬件控制的FPGA。例如,前面提供的主程序代码中,用检测按键的方式触发DMA实现采样。显然无论是检测按键的程序的时间精度还是程序调用外设库启动DMA传输的时间精度都远远低于ADC采样的100ns数量级的时间精度。致使采样触发信号的实时性只能满足对"时间平稳信号"分析的需要,无法达到对非平稳信号进行时域分析的需求。
2)另外,从图6中可以发现信号刚开始的一段信号是混乱的,造成混乱的原因有二:
其一:在高速传输条件下,在传输刚开始的一段时间****STM32的DMA控制器无法及时的响应传输请求,从而造成DMA只能在TIM1的采样请求已经发出一段时间后才读取ADC的输出数据,结果自然不正确。
其二:流水线型ADC的数据传输和采样值之间存在延迟。从图3给出的时序图也可以发现,当前读取的数据是四个时钟之前"潜伏"在ADC的流水线中的,从而造成了缓冲区中开始一段的信号错误。
以上两个原因,都可以通过丢掉缓冲器中开始的一段数据的方法掩盖,但这无疑也是对采样触发信号的实时性的进一步降低。
2、TIM1输出采样时钟抖动问题及其解决办法
下图是用20G采样率的示波器DSOX6004A采集到的TIM1_CH2(PE11)输出的采样时钟信号。
图7 TIM1的PWM功能产生的采样时钟的孔径抖动(触发后100us处)
为了测试采样时钟信号的孔径抖动情况,我将观察窗对准采样触发后100us的地方(触发-采样延迟如图中红色圈内数据所示),可以发现该处时钟上升沿的抖动达到了5ns左右(图中示波器横轴每个为5ns,如图中黄色圈所示)。这表明TIM1的OC模块产生的采样时钟孔径抖动品质较差,大大降低了采样信号的信噪比。
为解决这个问题,可以使用片外模拟锁相环PLL输出的时钟信号作为ADC的采样时钟。至于STM32F407的TIM1_CH2则由输出比较模式(OC)变为输入捕获模式(IC),由外部锁相环产生的时钟信号作为TIM1_CH2的捕获(IC)对象。 下图是我使用单独的模拟锁相环PLL芯片Si5351产生相同的21MHz信号,同样在采样触发后100us的地方观察时钟抖动情况。可以明显的看到锁相环芯片产生的时钟的孔径抖动性能明显由于定时器输出比较模式输出的时钟。
图8 模拟锁相环芯片Si5351产生的采样时钟的孔径抖动(触发后100us处)
3、同步传输速率被限制在10MSPS左右
从本质上讲,DMA是与CPU内核共享片上的总线资源的,当使用DMA高速传输并行数据时必然挤占CPU读取指令和数据的总线时间。如果DMA传输的是SRAM中的数据,某一笔传输由于总线被占用而延期并不会影响传输整体的正确性。但对于ADC和DAC这样的高速数据传输,某一笔数据的延迟就有可能造成采样的错误。
经过测试,我整体的感觉是:即使把DMA传输数据的优先级设为非常高(DMA_Priority_VeryHigh),且在采集期间不执行中断服务(ISR)等可能打断DMA的程序,当把DMA同步传输速率提升到10MSPS以上就很难保证每一笔传输的可靠实时性了。当然在极限情况下,同步传输速率是可以达到40MSPS的,但不建议大家在产品中使用。
4、单端数字信号,抗干扰能力弱于差分数据线
当数字信号在PCB上传输的速率达到10MSPS以上时,STM32中使用的单端3.3V CMOS在很多情况下就有可能出现传输错误,一般的解决方案是使用LVDS等差分传输标准。但STM32F4系列中没有类似硬件配置,导致同步传输速率达到10MSPS以上时系统的抗干扰能力和传输正确率都会有所下降。