4. STM32底层驱动开发:GPIO、定时器PWM、正交编码器接口、中断系统、串口通信

做AGV底盘控制,说白了就是跟STM32的这几个外设打交道。GPIO、定时器、编码器接口、中断、串口——这五个东西你玩熟了,底盘驱动就搞定了一半。我这些年调试过的AGV,从两轮差速到四轮麦克纳姆,底层逻辑都没跳出这个圈子。

4.1 GPIO:底盘控制的“手脚”

GPIO是STM32最基础的外设。你想想看,电机驱动器的使能引脚、方向控制、刹车信号,哪个不是靠GPIO?

我个人习惯把GPIO配置分成三步:

  1. 时钟使能——别漏了这一步,我见过太多新手卡在这里
  2. 模式配置——推挽输出、开漏输出、浮空输入,选对模式很重要
  3. 速度设置——控制信号用低速,PWM用高速

核心要点:AGV的电机使能引脚,我建议用推挽输出,速度选50MHz就够了。别用开漏,除非你外接上拉电阻。

举个例子,控制一个直流电机正反转:

// 电机A控制引脚初始化
void Motor_GPIO_Init(void) {
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    __HAL_RCC_GPIOA_CLK_ENABLE();   // 时钟使能,这步忘了就全白搭
    
    GPIO_InitStruct.Pin = GPIO_PIN_0 | GPIO_PIN_1;  // PA0:方向, PA1:使能
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;     // 推挽输出
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;   // 高速
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}

// 正转
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);   // 方向高电平
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET);   // 使能高电平

小技巧:调试时我喜欢用逻辑分析仪抓GPIO波形。有一次电机抖动,查了半天发现是GPIO初始化顺序错了——先使能了电机才配置方向。嗯,顺序很重要。

4.2 定时器PWM:控制电机速度的灵魂

AGV要跑多快,全靠PWM说了算。STM32的定时器功能很强,但咱们做底盘控制,主要用它的PWM输出和编码器模式。

配置PWM的几个关键参数:

  • 预分频器(PSC):决定计数时钟频率
  • 自动重装载值(ARR):决定PWM周期
  • 比较值(CCR):决定占空比

我一般这样算:目标PWM频率50Hz(周期20ms),系统时钟72MHz。

// 定时器2 PWM初始化,通道1输出
void PWM_Init(void) {
    TIM_OC_InitTypeDef sConfigOC = {0};
    TIM_HandleTypeDef htim2;
    
    htim2.Instance = TIM2;
    htim2.Init.Prescaler = 72 - 1;          // 72MHz / 72 = 1MHz
    htim2.Init.Period = 20000 - 1;          // 1MHz / 20000 = 50Hz
    htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
    HAL_TIM_PWM_Init(&htim2);
    
    sConfigOC.OCMode = TIM_OCMODE_PWM1;     // PWM模式1
    sConfigOC.Pulse = 1500;                 // 初始占空比1500us
    sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
    HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1);
    
    HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
}

// 设置占空比,范围1000~2000对应-90度~+90度
void Set_PWM_Duty(uint16_t duty) {
    __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, duty);
}

注意:我曾经在项目里直接用HAL_Delay()做PWM控制,结果电机一卡一卡的。后来才发现,HAL_Delay依赖SysTick中断,中断一多就乱套。PWM一定要用硬件定时器,别用软件模拟。

4.3 正交编码器接口:AGV的“眼睛”

AGV要知道自己走了多远、转了多少角度,全靠编码器。STM32的定时器自带编码器接口模式,用起来很方便。

编码器接口的核心原理:

  • 两路信号(A相、B相)相位差90度
  • 正转时A相超前B相,反转时B相超前A相
  • 定时器自动计数,方向自动判断

我习惯用4倍频模式,精度更高:

// 定时器3编码器模式初始化
void Encoder_Init(void) {
    TIM_Encoder_InitTypeDef sEncoderConfig = {0};
    TIM_HandleTypeDef htim3;
    
    htim3.Instance = TIM3;
    htim3.Init.Prescaler = 0;               // 不分频
    htim3.Init.CounterMode = TIM_COUNTERMODE_UP;
    htim3.Init.Period = 0xFFFF;             // 最大计数值
    
    sEncoderConfig.EncoderMode = TIM_ENCODERMODE_TI12;  // 4倍频模式
    sEncoderConfig.IC1Polarity = TIM_ICPOLARITY_RISING;
    sEncoderConfig.IC2Polarity = TIM_ICPOLARITY_RISING;
    HAL_TIM_Encoder_Init(&htim3, &sEncoderConfig);
    
    HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL);
}

// 读取编码器计数值
int16_t Get_Encoder_Count(void) {
    return (int16_t)__HAL_TIM_GET_COUNTER(&htim3);
}

避坑指南:我曾经遇到过编码器读数跳变的问题。查了半天,发现是编码器线太长,信号有干扰。后来加了RC滤波,问题就解决了。编码器线建议用屏蔽线,长度别超过3米。

4.4 中断系统:让CPU“忙而不乱”

AGV控制里,中断无处不在。定时器溢出中断、编码器更新中断、串口接收中断——没有中断,CPU就得一直轮询,效率低得可怜。

STM32的中断优先级分组,我建议用NVIC_PriorityGroup_2(2位抢占优先级,2位子优先级)。这样够用,也不复杂。

中断处理的原则:

  • 快进快出——中断里别做复杂运算
  • 标志位传递——用全局变量通知主循环
  • 优先级合理——编码器中断优先级高于串口
// 定时器更新中断回调
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
    if(htim->Instance == TIM2) {
        // 50ms执行一次,用于速度计算
        speed_calc_flag = 1;    // 设置标志位,主循环处理
    }
}

// 主循环中处理
while(1) {
    if(speed_calc_flag) {
        speed_calc_flag = 0;
        Calculate_Speed();      // 计算速度
    }
}

经验之谈:中断里千万别用HAL_Delay()!SysTick中断优先级比你的外设中断低时,HAL_Delay会死等,直接卡死。我刚开始学STM32时就踩过这个坑,调试了一下午。

4.5 串口通信:AGV的“嘴巴”

AGV要跟主控通信,串口是最常用的方式。ROS里serial包、自定义协议,都离不开串口。

串口配置的几个要点:

  • 波特率——115200是AGV常用值,够快也稳定
  • 数据位8位、停止位1位、无校验——标准配置
  • DMA+空闲中断——接收不定长数据的最佳方案

我推荐用DMA+空闲中断的方式接收数据,效率最高:

// 串口DMA接收初始化
void UART_DMA_Init(void) {
    // 配置串口1,波特率115200
    // 使能DMA1通道5(USART1_RX)
    // 使能空闲中断
    
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);  // 使能空闲中断
    HAL_UART_Receive_DMA(&huart1, rx_buffer, BUFFER_SIZE);
}

// 空闲中断处理
void USART1_IRQHandler(void) {
    if(__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        
        // 计算接收到的数据长度
        rx_len = BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
        
        // 处理接收到的数据
        Process_Received_Data(rx_buffer, rx_len);
        
        // 重新开始接收
        HAL_UART_Receive_DMA(&huart1, rx_buffer, BUFFER_SIZE);
    }
}

注意:串口通信一定要加协议!我见过太多人直接发裸数据,结果一有干扰就乱套。最简单的协议:帧头(0xAA)+数据长度+数据+校验和。别嫌麻烦,这是血的教训。

4.6 知识体系总览

这五个外设的关系,我用一张图来总结:

STM32底层驱动开发知识体系 STM32外设驱动 GPIO 定时器PWM 正交编码器 中断系统 串口通信 AGV底盘控制应用层 电机使能/方向控制 速度控制/舵机控制 位置/速度反馈 实时响应/任务调度 与主控通信/数据交互

这张图把五个外设的关系理清楚了。GPIO和PWM负责输出控制,编码器负责输入反馈,中断负责实时响应,串口负责对外通信。它们配合起来,AGV底盘才能动起来、跑得稳。

总结一下:做AGV底层驱动,别想着一步到位。先把GPIO点亮LED,再用定时器输出PWM让电机转起来,接着接上编码器读位置,然后用中断做定时控制,最后用串口跟ROS通信。一步一步来,稳扎稳打。

嗯,这五个外设的驱动开发就讲到这里。代码我都实际跑过,你照着写应该没问题。遇到坑了别慌,先查时钟配置,再看引脚复用,八成能解决。