一、概述
无论是新手还是大佬,基于STM32单片机的开发,使用STM32CubeMX都是可以极大提升开发效率的,并且其界面化的开发,也大大降低了新手对STM32单片机的开发门槛。
本文主要讲述STM32芯片的DMA的配置及其相关知识。
二、软件说明
STM32CubeMX是ST官方出的一款针对ST的MCU/MPU跨平台的图形化工具,支持在Linux、MacOS、Window系统下开发,其对接的底层接口是HAL库,另外习惯于寄存器开发的同学们,也可以使用LL库。STM32CubeMX除了集成MCU/MPU的硬件抽象层,另外还集成了像RTOS,文件系统,USB,网络,显示,嵌入式AI等中间件,这样开发者就能够很轻松的完成MCU/MPU的底层驱动的配置,留出更多精力开发上层功能逻辑,能够更进一步提高了嵌入式开发效率。
演示版本 6.7.0
三、DMA简介
DMA(Direct Memory Access)直接内存访问,其实就是一个数据搬运工,负责将数据从一个地方搬运到另一个地方而不需要内核介入。STM32里的DMA支持从外设到内存,从内存到外设和从内存到内存三种传输方式。
老规矩,先来看下ST芯片手册里DMA的框架图。
从此图可以看出,DMA的数据来源与去向可以源自于各种外设,当然这只限于目前的这款芯片,有些芯片DMA不能访问部分外设,如ST的H750,具体能否访问需要看芯片手册里的总线框架。
我们先把这图拆成四部分:
首先是中间的"Bus matrix"(总线矩阵),是这个图的核心,所有的外设及内核,都是通过总线矩阵进行数据交互的。
左上角就是F072这款芯片的内核,用的是Cortex-M0。内核需要通过总线矩阵才能跟其他外设进行数据交互。
右侧是与本文关系不大的其他外设,包括Flash、SRAM及GPIO等其他外设。
而左下角就是本文的主角——DMA。
前面也说了,DMA就是一个数据搬运工,那什么时候需要这个搬运工呢?一般有两种场景,一是有大量数据需要传输,二是需要内核频繁切换搬运的数据传输。
比如现在要做一个显示屏,用到了一个GUI开源库LVGL,这个库用于显示的接口是操作一段缓存数据,这段缓存叫作显存。如果要操作一个16位480*272分辨率的RGB屏,那这段显存的大小就是480*272*2=255k。如果使用内核来填充这段显存,以48M单片机主频来算,假设一个指令周期填充四个字节的数据,那么需要48000000/(255*1024/4)=735us时间。如果屏更大,那花费的时间就更长了。此时内核用于显示占用了过多的时间,就会导致其他操作无法进行。所以像这种无聊单调的搬运工作,完全没必要内核来进行,只需要有个搬运工,简单地配置后,让它自己按时搬运数据,解放内核的双手,让内核可以去做其他更有意义的事。
再比如现在要实现一个串口的数据收发,ST的串口只提供了一个字节的收发(新的系列提供一段FIFO队列,这里先不考虑这种情况),那如果需要收发100个字节的数据,意味着需要搬运100次数据。相较于上面的255k,可能觉得这100次没什么大不了,但是串口通信是有速率限制的,比如一个9600波特率(1s传输9600个位),传一个字节就需要1ms左右,100个字节就需要100ms。如果使用的是阻塞型发送,即意味着内核需要阻塞100ms的时间。如果使用的中断式发送,那也需要在中断跟主循环中来回切换100次,效率太低。所以像这种重复搬运的,也可以使用DMA来帮忙。
四、功能配置 及 代码实现
这里我们整两个比较常用的实例吧,实例一:使用ADC+DMA。实例二:使用Uart+DMA。
4.1 ADC+DMA
4.1.1 功能配置
这里我们试着一次采三个通道,分别是片内温度、参考电压和备份电源电压。
配置好ADC,ADC的配置可以参考《STM32CubeMX-单ADC模式规则通道配置》。然后在ADC配置的基础上增加DMA的配置。注意,用HAL库的时候,千万不要配置连续转换!!因为HAL库的DMA中断操作时间过长,比ADC转换一次的时间还长,导致程序会一直频繁进DMA中断。
DMA Setting(DMA配置):DMA的基本功能配置窗口。
DMA Request(DMA请求来源):这个一般从哪个外设点进来就默认用哪个外设。
Channel(DMA通道ID):DMA一般有16个通道,当使用了多个DMA通道进行传输时,CubeMX会自动跳过已选择的通道,不用担心会选重。但如果不是通过CubeMX配置的话就要注意去重了。另外,不是所有通道都可以选,芯片手册有写明哪些外设只能用哪些通道,CubeMX会自动给你去除掉不可配置的通道。
Direction(数据传输方向):DMA本身是可以支持"外设到内存"、“内存到外设"和"内存到内存"这三个数据传输方向的,但因为这里选择了源数据为ADC,所以只能选择"外设到内存”。
Priority(传输优先级):这里区分了低、中、高、非常高四种优先级。前面说了因为DMA会有多个通道,所以当同时配多个通道时,并且同时有多个传输请求时,这时候就需要区分优先级看哪个先传哪个后传。如果都是一样的优先级,那就按通道的顺序执行。
Add/Delete(添加或删除):用于添加或删除DMA通道,因为使用的是ADC,只需要一个DMA通道即可解决,所以无法添加第二个DMA通道。
Mode(请求模式):可以选择单次或循环,如果选择了单次,那DMA会在一轮数据传输后停止传输;如果选择的是循环,则在传输完一轮数据之后自动进行下一轮的传输。
Increment Address(递增地址):勾选则表示每传一个数据,其对应的物理地址要递增一次。因为这里ADC多个通道采集后的数据都存放在DR这一个寄存器里,所以其物理地址不需要变化,而传输到内存后,如果内存地址不向上加1,则多个通道的数据会被相互覆盖。所以为了达成多个通道的数据自动采集放到不同内存地址,这里需要勾选内存地址递增。
Data Width(数据带宽):每传输一个数据的位数,可以选择8位、16位和32位。需要注意的是,对应的内存地址需要与数据类型的位数保持对齐,也就是说如果选择了16位数据传输,则用于存放的内存地址必须能被2整除;传输32位的数据则其内存地址需要被4整除。如果地址不对齐会导致数据传输后错位。
上面的DMA功能配置完后,还需要翻到前面ADC的设置页,使能DMA的请求功能。
4.1.2 代码实现
查看手册,找到温度的换算公式如下,其中TS_CAL1和TS_CAL2可以在数据手册中找到对应存放的内存地址,而TS_DATA则是当前的采集值。
VREFINT(参考电压)及VBAT(备份电源电压)的换算公式,其中VREFINT_CAL可以在数据手册中找到对应存放的内存地址,VREFINT_DATA就是采集VREFINT的ADC值。
把VREFINT_DATA换成VBAT_DATA * 2得出来的结果就是VBAT的值。乘2是因为手册里写的,VBAT有可能会大于VDD,为了防止输入的备份电源过高损坏单片机,所以单片机内部做了分压,最终给到ADC采集的值是二分后的电压。
/* Private user code ---------------------------------------------------------*/
/* USER CODE BEGIN 0 */
/* ADC采集类型 */
enum emAdcType
{
ADC_TYPE_temperature = 0,
ADC_TYPE_vrefint = 1,
ADC_TYPE_vbat = 2,
ADC_TYPE_max
};
/* 换算结果缓存 */
float AdcData[ADC_TYPE_max] = {0, 0, 0};
/* USER CODE END 0 */
/**
* @brief The application entry point.
* @retval int
*/
int main(void)
{
/* USER CODE BEGIN 1 */
/* 采集结果缓存 */
uint16_t adc_buff[ADC_TYPE_max] = {0, 0, 0};
/* USER CODE END 1 */
/* MCU Configuration--------------------------------------------------------*/
/* Reset of all peripherals, Initializes the Flash interface and the Systick. */
HAL_Init();
/* USER CODE BEGIN Init */
/* USER CODE END Init */
/* Configure the system clock */
SystemClock_Config();
/* USER CODE BEGIN SysInit */
/* USER CODE END SysInit */
/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_DMA_Init();
MX_ADC_Init();
/* USER CODE BEGIN 2 */
/* USER CODE END 2 */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
/* 启动采集 */
HAL_ADC_Start_DMA(&hadc, (uint32_t *)adc_buff, ADC_TYPE_max);
/* 温度换算 */
#define TS_CAL1 ((uint16_t *)0x1FFFF7B8)
#define TS_CAL2 ((uint16_t *)0x1FFFF7C2)
AdcData[ADC_TYPE_temperature] = (float)(110 - 30) / ((*TS_CAL2) - (*TS_CAL1)) * (adc_buff[ADC_TYPE_temperature] - (*TS_CAL1)) + 30;
/* 参考电压换算 */
#define VREFINT_CAL ((uint16_t *)0x1FFFF7BA)
AdcData[ADC_TYPE_vrefint] = (float)3 * (*VREFINT_CAL) / adc_buff[ADC_TYPE_vrefint];
/* 备份电源电压换算 */
AdcData[ADC_TYPE_vbat] = (float)3 * (*VREFINT_CAL) * 2 / adc_buff[ADC_TYPE_vbat];
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */
}
4.1.3 效果演示
注:很奇怪这里的温度按手册的公式算出来一直是50几度,换了几块开发板都这样,后面有时间研究一下。
4.2 Uart+DMA
这里我们来实现一个比较好玩的功能——串口透传,也就是开启两个串口,当一个串口接收到数据时,把数据给到另一个串口,由另一个串口发送出去;反过来同理。有人可能会说了,这样为什么不用板子直接把两个线接起来就行?我只能说这个功能,实际项目中就会用到,比如串口网关或控制器带透传功能等。话不多说,重点来看下实现。(由于F072的DMA不支持交叉通道配置,所以这里咋偷偷切换个C031来实现外设到外设的配置)。
4.2.1 功能配置
这里我们来实现一个接收和发送都用DMA搬运的例子。
同样的先配置好Uart,Uart的配置可以参考《STM32CubeMX-Uart配置 及 数据收发功能实现》。
注:这里串口1的发送口不要配在PC14,因为C031的PC14脚跟烧录引脚复用,需要有其他配置才能作为串口发送。
然后在Uart配置的基础上增加DMA的配置。不同于ADC,Uart有收跟发两个传输方向,所以这里DMA可以配置两个通道,一个用于数据接收,一个用于数据发送。透传这里我们打算当接收到一个字节的数据时,使用DMA传输至另一个串口的TDR寄存器发送出去,所以这里只需要配置接收的DMA通道。
因为这里串口的收发寄存器都只有一个,所以Memory地址也不需要递增。另外打开串口中断是为了在发送完一帧数据后,重置一下DMA的配置(因为现在DMA配置的是单次发送,所以每发完一次需要重新配置一次)。
4.2.2 代码实现
/***************************************main.c*********************************************/
/* USER CODE BEGIN 0 */
void Uart_PassThroughEn1(void)
{
/* DMA失能 */
LL_DMA_DisableChannel(DMA1, LL_DMA_CHANNEL_1);
/* 设置DMA数据源 */
LL_DMA_SetPeriphAddress(DMA1, LL_DMA_CHANNEL_1, LL_USART_DMA_GetRegAddr(USART1, LL_USART_DMA_REG_DATA_RECEIVE));
/* 设置DMA目标数据地址为另一路串口的发送寄存器地址 */
LL_DMA_SetMemoryAddress(DMA1, LL_DMA_CHANNEL_1, LL_USART_DMA_GetRegAddr(USART2, LL_USART_DMA_REG_DATA_TRANSMIT));
/* 设置DMA数据长度-这里设置的是一次性最大能传输的数量为255 */
LL_DMA_SetDataLength(DMA1, LL_DMA_CHANNEL_1, 255);
/* 打开接收的DMA传输使能 */
LL_USART_EnableDMAReq_RX(USART1);
/* 开DMA使能前清除标志 */
LL_DMA_ClearFlag_TC1(DMA1);
LL_DMA_ClearFlag_HT1(DMA1);
/* DMA使能 */
LL_DMA_EnableChannel(DMA1, LL_DMA_CHANNEL_1);
/* 清除TC标志 */
LL_USART_ClearFlag_TC(USART1);
/* 使能TC中断 */
LL_USART_EnableIT_TC(USART1);
}
void Uart_PassThroughEn2(void)
{
/* DMA失能 */
LL_DMA_DisableChannel(DMA1, LL_DMA_CHANNEL_2);
/* 设置DMA数据源 */
LL_DMA_SetPeriphAddress(DMA1, LL_DMA_CHANNEL_2, LL_USART_DMA_GetRegAddr(USART2, LL_USART_DMA_REG_DATA_RECEIVE));
/* 设置DMA目标数据地址为另一路串口的发送寄存器地址 */
LL_DMA_SetMemoryAddress(DMA1, LL_DMA_CHANNEL_2, LL_USART_DMA_GetRegAddr(USART1, LL_USART_DMA_REG_DATA_TRANSMIT));
/* 设置DMA数据长度-这里设置的是一次性最大能传输的数量为255 */
LL_DMA_SetDataLength(DMA1, LL_DMA_CHANNEL_2, 255);
/* 打开接收的DMA传输使能 */
LL_USART_EnableDMAReq_RX(USART2);
/* 开DMA使能前清除标志 */
LL_DMA_ClearFlag_TC1(DMA1);
LL_DMA_ClearFlag_HT1(DMA1);
/* DMA使能 */
LL_DMA_EnableChannel(DMA1, LL_DMA_CHANNEL_2);
/* 清除TC标志 */
LL_USART_ClearFlag_TC(USART2);
/* 使能TC中断 */
LL_USART_EnableIT_TC(USART2);
}
/* USER CODE END 0 */
int main(void)
{
/* USER CODE BEGIN 1 */
/* USER CODE END 1 */
/* MCU Configuration--------------------------------------------------------*/
/* Reset of all peripherals, Initializes the Flash interface and the Systick. */
LL_APB2_GRP1_EnableClock(LL_APB2_GRP1_PERIPH_SYSCFG);
LL_APB1_GRP1_EnableClock(LL_APB1_GRP1_PERIPH_PWR);
/* SysTick_IRQn interrupt configuration */
NVIC_SetPriority(SysTick_IRQn, 3);
/* USER CODE BEGIN Init */
/* USER CODE END Init */
/* Configure the system clock */
SystemClock_Config();
/* USER CODE BEGIN SysInit */
/* USER CODE END SysInit */
/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
MX_USART2_UART_Init();
/* USER CODE BEGIN 2 */
Uart_PassThroughEn1();
Uart_PassThroughEn2();
/* USER CODE END 2 */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
/* USER CODE END 3 */
}
/***************************************stm32f0xx_it.c*********************************************/
/* Private user code ---------------------------------------------------------*/
/* USER CODE BEGIN 0 */
extern void Uart_PassThroughEn1(void);
extern void Uart_PassThroughEn2(void);
/* USER CODE END 0 */
/**
* @brief This function handles USART1 interrupt.
*/
void USART1_IRQHandler(void)
{
/* USER CODE BEGIN USART1_IRQn 0 */
if ( (LL_USART_IsEnabledIT_TC(USART1))
&& (LL_USART_IsActiveFlag_TC(USART1))
)
{
Uart_PassThroughEn1();
LL_USART_ClearFlag_TC(USART1);
}
/* USER CODE END USART1_IRQn 0 */
/* USER CODE BEGIN USART1_IRQn 1 */
/* USER CODE END USART1_IRQn 1 */
}
/**
* @brief This function handles USART2 interrupt.
*/
void USART2_IRQHandler(void)
{
/* USER CODE BEGIN USART2_IRQn 0 */
if ( (LL_USART_IsEnabledIT_TC(USART2))
&& (LL_USART_IsActiveFlag_TC(USART2))
)
{
Uart_PassThroughEn2();
LL_USART_ClearFlag_TC(USART2);
}
/* USER CODE END USART2_IRQn 0 */
/* USER CODE BEGIN USART2_IRQn 1 */
/* USER CODE END USART2_IRQn 1 */
}
4.2.3 效果演示
五、注意事项
1、使用ADC+DMA时,要留意一下生成代码的初始化顺序,之前出现过生成的代码ADC与DMA的初始化顺序错了,导致ADC配置的通道数被异常修改。
2、DMA传输的内存地址必须与传输的数据类型对齐,如果传输的是16位的数据,则其源和目标内存的地址都必须是2的整数倍;如果传输的是32位的数据,则必须是4的整数倍。如果不对齐DMA会强行访问对齐的地址,结果就是数据错误。
3、使用ADC+DMA时,如果要用HAL库,不能开启连续转换模式。因为HAL库里DMA的中断处理时间长于ADC的采样时间,导致开启传输后,程序几乎一直在DMA中断里出不来,造成程序“假死”的现象。
六、相关链接
【知识分享】异步串行收发器Uart(串口)-通信协议详解
【工具使用】STM32CubeMX-单ADC模式规则通道配置
【工具使用】STM32CubeMX-Uart配置 及 数据收发功能实现