2、STM32平台基础与裸机开发:时钟树、GPIO、中断系统、定时器、DMA

好,咱们正式开始啃STM32这块硬骨头。说实话,很多新手一上来就怼HAL库、怼RTOS,结果出了问题连基本的外设都调不明白。我个人习惯是,先把裸机玩透了,再去碰那些封装好的东西。这一章,咱们就把STM32的五个基本功——时钟树、GPIO、中断、定时器、DMA,一个一个捋清楚。

2.1 时钟树——芯片的心脏起搏器

时钟,说白了就是芯片的“心跳”。没有时钟,CPU、外设全都歇菜。STM32的时钟树比51单片机复杂得多,我第一次看参考手册的时钟树图时,说实话有点懵。但后来发现,只要抓住几个关键节点,其实没那么玄乎。

2.1.1 时钟源有哪些?

STM32有四个主要的时钟源:

  • HSI(高速内部时钟):8MHz RC振荡器,上电默认就用它。精度一般,温漂大,但胜在启动快。
  • HSE(高速外部时钟):通常接4-16MHz晶振,精度高,适合对时序敏感的场景。
  • LSI(低速内部时钟):40kHz左右,专门给独立看门狗和RTC用。
  • LSE(低速外部时钟):32.768kHz,RTC的标配,走时准。
我的习惯:只要板子上有外部晶振,我一般都会切到HSE。HSI虽然省事,但我在一个项目中遇到过因为温度变化导致串口波特率漂移的问题,查了两天才发现是HSI惹的祸。

2.1.2 PLL锁相环——超频的秘密

STM32内部有个PLL,可以把时钟倍频上去。比如F103,HSE是8MHz,通过PLL可以倍频到72MHz。配置PLL时要注意:

  • PLL的输入频率必须在2-16MHz之间(不同系列略有差异)
  • 倍频系数不能乱设,要保证输出不超过芯片最大频率
  • APB1和APB2总线时钟要分别配置,APB1最高36MHz,APB2最高72MHz
// 以STM32F103为例,配置系统时钟为72MHz
void SystemClock_Config(void)
{
    // 1. 开启HSE并等待就绪
    RCC->CR |= RCC_CR_HSEON;
    while(!(RCC->CR & RCC_CR_HSERDY));
    
    // 2. 配置PLL:HSE 8MHz * 9 = 72MHz
    RCC->CFGR |= RCC_CFGR_PLLSRC_HSE | RCC_CFGR_PLLMULL9;
    RCC->CR |= RCC_CR_PLLON;
    while(!(RCC->CR & RCC_CR_PLLRDY));
    
    // 3. 切换系统时钟到PLL输出
    RCC->CFGR |= RCC_CFGR_SW_PLL;
    while((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);
    
    // 4. 配置APB1和APB2分频
    RCC->CFGR |= RCC_CFGR_PPRE1_DIV2;  // APB1 = 36MHz
    RCC->CFGR |= RCC_CFGR_PPRE2_DIV1;  // APB2 = 72MHz
}
我曾经踩过的坑:切换时钟源时,一定要等标志位就绪再往下走。有一次我偷懒没检查PLL就绪标志,结果芯片直接跑飞了,查了半天才发现是时钟还没稳定就切过去了。

2.2 GPIO——最基础也最容易出错的外设

GPIO,每个嵌入式工程师的入门课。但你真的会用吗?STM32的GPIO有8种工作模式,不是简单的输入输出就完事了。

2.2.1 八种模式速览

模式 用途 典型场景
浮空输入 电平不确定时用 外部中断输入
上拉输入 默认高电平 按键检测(按下为低)
下拉输入 默认低电平 按键检测(按下为高)
模拟输入 ADC专用 采集模拟信号
推挽输出 最常用输出模式 LED、蜂鸣器
开漏输出 需要外部上拉 I2C总线
复用推挽 外设功能输出 USART_TX、SPI_SCK
复用开漏 外设功能开漏 I2C_SCL、I2C_SDA

2.2.2 配置GPIO的步骤

配置一个GPIO,其实就三步:开时钟、设模式、写数据。但顺序不能乱。

// 配置PA5为推挽输出,驱动LED
void GPIO_Config(void)
{
    // 第一步:开启GPIOA时钟
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
    
    // 第二步:配置模式寄存器
    // PA5对应CRL的位20-23,设置为通用推挽输出,50MHz
    GPIOA->CRL &= ~(0xF << 20);  // 先清零
    GPIOA->CRL |= (0x2 << 20);   // 0x2 = 推挽输出,50MHz
    
    // 第三步:输出高电平
    GPIOA->BSRR = GPIO_BSRR_BS5;  // 置位
}
一个小技巧:用BSRR寄存器操作GPIO,比用ODR寄存器快,而且不会产生中断。我在飞控的PWM输出中就用了BSRR,避免了GPIO翻转时的毛刺问题。

2.3 中断系统——让CPU学会“分心”

轮询太浪费CPU了。你想想看,如果CPU一直在那死等一个按键按下,那其他事还干不干了?中断就是让CPU能“分心”处理多任务的关键。

2.3.1 NVIC——中断的调度中心

STM32用NVIC(嵌套向量中断控制器)来管理中断。每个中断都有优先级,高优先级可以打断低优先级。优先级分组是个容易搞混的地方:

  • 抢占优先级:数字越小,优先级越高,可以打断正在执行的低优先级中断
  • 响应优先级:数字越小,优先级越高,但不能打断,只能排队
// 配置外部中断EXTI0,优先级分组为2(4位抢占,4位响应)
void NVIC_Config(void)
{
    NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
    
    NVIC_InitTypeDef NVIC_InitStructure;
    NVIC_InitStructure.NVIC_IRQChannel = EXTI0_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;  // 抢占优先级
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;         // 响应优先级
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);
}
我曾经犯过的错:中断服务函数里千万别做复杂运算,更别用printf。有一次我在中断里调了个延时函数,结果整个系统卡死了。中断服务函数的原则就是:快进快出,只做标志位处理。

2.3.2 外部中断EXTI

EXTI可以检测GPIO的上升沿、下降沿或双边沿。配置时要注意:

  • 每个EXTI线只能对应一个GPIO引脚
  • EXTI0-4有独立的中断号,EXTI5-9共用,EXTI10-15共用
  • 中断标志位要手动清除,否则会一直触发
// 配置PA0为下降沿触发的外部中断
void EXTI_Config(void)
{
    // 1. 开启GPIOA和AFIO时钟
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_AFIOEN;
    
    // 2. 配置PA0为浮空输入
    GPIOA->CRL &= ~(0xF << 0);
    GPIOA->CRL |= (0x4 << 0);  // 0x4 = 浮空输入
    
    // 3. 配置EXTI0连接到PA0
    AFIO->EXTICR[0] &= ~AFIO_EXTICR1_EXTI0;
    AFIO->EXTICR[0] |= AFIO_EXTICR1_EXTI0_PA;
    
    // 4. 配置EXTI0为下降沿触发
    EXTI->IMR |= EXTI_IMR_MR0;    // 取消屏蔽
    EXTI->FTSR |= EXTI_FTSR_TR0;  // 下降沿触发
    
    // 5. 配置NVIC(略)
}

2.4 定时器——精确的时间控制

定时器是飞控里最常用的外设之一。PWM输出、输入捕获、编码器接口,全都靠它。STM32的定时器分三类:基本定时器、通用定时器、高级定时器。

2.4.1 定时器的工作原理

说白了,定时器就是一个不断累加的计数器。当计数值达到预设的自动重装载值时,就会产生溢出事件。配置定时器就是配置三个参数:

  • 预分频器(PSC):对时钟源进行分频
  • 自动重装载值(ARR):计数的上限
  • 计数模式:向上、向下、中央对齐
// 配置TIM2为1ms中断一次(72MHz时钟)
void TIM2_Config(void)
{
    // 1. 开启TIM2时钟
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
    
    // 2. 配置预分频器:72MHz / 72 = 1MHz
    TIM2->PSC = 71;  // 注意:实际分频系数是PSC+1
    
    // 3. 配置自动重装载值:1MHz / 1000 = 1kHz,即1ms
    TIM2->ARR = 999;  // 注意:实际计数值是ARR+1
    
    // 4. 清除更新标志,使能更新中断
    TIM2->SR = ~TIM_SR_UIF;
    TIM2->DIER |= TIM_DIER_UIE;
    
    // 5. 使能定时器
    TIM2->CR1 |= TIM_CR1_CEN;
}
我的经验:配置定时器时,PSC和ARR的取值要合理。PSC太小,定时器分辨率高但溢出快;PSC太大,分辨率低但定时时间长。我一般先确定需要的分辨率,再算PSC,最后算ARR。

2.4.2 PWM输出——飞控的肌肉

飞控控制电机靠的就是PWM。STM32的高级定时器可以输出带死区插入的互补PWM,非常适合驱动无刷电机。

// 配置TIM1_CH1输出PWM,频率50Hz,占空比可调
void PWM_Config(void)
{
    // 1. 开启TIM1和GPIOA时钟
    RCC->APB2ENR |= RCC_APB2ENR_TIM1EN | RCC_APB2ENR_IOPAEN;
    
    // 2. 配置PA8为复用推挽输出
    GPIOA->CRH &= ~(0xF << 0);
    GPIOA->CRH |= (0xB << 0);  // 0xB = 复用推挽输出,50MHz
    
    // 3. 配置TIM1
    TIM1->PSC = 71;           // 72MHz / 72 = 1MHz
    TIM1->ARR = 19999;        // 1MHz / 20000 = 50Hz
    TIM1->CCR1 = 1500;        // 占空比 = 1500/20000 = 7.5%
    
    // 4. 配置PWM模式1
    TIM1->CCMR1 |= TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2;  // PWM模式1
    TIM1->CCER |= TIM_CCER_CC1E;  // 使能输出
    
    // 5. 使能TIM1
    TIM1->BDTR |= TIM_BDTR_MOE;   // 主输出使能(高级定时器特有)
    TIM1->CR1 |= TIM_CR1_CEN;
}

2.5 DMA——解放CPU的搬运工

DMA,全称直接存储器访问。说白了,就是让数据在内存和外设之间自动搬运,CPU只需要告诉DMA“从哪搬、搬到哪、搬多少”,然后就可以去干别的事了。

2.5.1 DMA的传输模式

模式 说明 典型应用
外设到内存 外设数据自动存入内存 ADC采集、USART接收
内存到外设 内存数据自动发送到外设 DAC输出、USART发送
内存到内存 内存数据拷贝 图像处理、数据搬移

2.5.2 实战:DMA+USART实现无中断发送

飞控中经常需要发送大量数据到地面站。如果用CPU逐字节发送,会占用大量时间。用DMA就轻松多了。

// 配置USART1的DMA发送
void DMA_USART_Config(void)
{
    // 1. 开启DMA1时钟
    RCC->AHBENR |= RCC_AHBENR_DMA1EN;
    
    // 2. 配置DMA通道4(USART1_TX)
    DMA1_Channel4->CPAR = (uint32_t)&USART1->DR;  // 外设地址
    DMA1_Channel4->CMAR = (uint32_t)tx_buffer;    // 内存地址
    DMA1_Channel4->CNDTR = sizeof(tx_buffer);     // 传输数据量
    
    // 3. 配置控制寄存器
    DMA1_Channel4->CCR |= DMA_CCR_MINC;    // 内存地址递增
    DMA1_Channel4->CCR |= DMA_CCR_DIR;     // 内存到外设
    DMA1_Channel4->CCR |= DMA_CCR_CIRC;    // 循环模式(可选)
    DMA1_Channel4->CCR |= DMA_CCR_EN;      // 使能DMA通道
    
    // 4. 使能USART1的DMA发送
    USART1->CR3 |= USART_CR3_DMAT;
}
我曾经踩过的坑:DMA传输完成后,一定要检查传输完成标志。有一次我忘了检查,结果数据还没传完就修改了缓冲区,导致发送了错误的数据。另外,DMA中断里不要做复杂操作,和普通中断一样,快进快出。

2.6 本章小结

嗯,这一章内容不少。时钟树、GPIO、中断、定时器、DMA,这五个是STM32裸机开发的核心。我个人觉得,把这五个玩透了,后面学什么外设都很快。你想想看,串口、I2C、SPI,哪个不是基于GPIO和中断?PWM、输入捕获,哪个不是基于定时器?

下一章,咱们开始讲串口通信和I2C/SPI总线协议。到时候我会结合飞控的实际应用,讲讲怎么用这些协议和传感器通信。咱们下章见。

课后练习:
  1. 用定时器实现一个1ms的软件定时器,可以同时管理5个不同的超时事件
  2. 用DMA+USART实现一个环形缓冲区,接收不定长数据
  3. 配置一个外部中断,检测飞控的紧急降落开关(下降沿触发)