💬

用STM32实现轮询IO

に公開

轮询IO是什么?

轮询IO是一种通过定时轮询的方式来控制输入或输出的技术。
简单来说,就是按照固定的时间间隔,读取DI或者写入DO端口。MCU(微控制器)会持续不断地进行这些操作。这种方法在工业设备的IO板卡中非常常见。
之前我写过一篇关于如何用Arduino实现轮询IO的文章,这次就来聊聊用STM32该怎么做。毕竟,Arduino大多是用在开发板和学习阶段,真正的产品里还是STM32这种芯片更常见。

控制流程简介

我们先简单说说轮询IO的基本实现流程。
在嵌入式开发中,一般都会在主函数里写一个死循环(while(1)),然后在循环中定期执行一些任务,比如IO更新。

想要实现轮询IO,通常需要准备以下几个部分:

  • 软件定时器
  • 定时器超时检查函数
  • IO更新函数
  • 设置定时器的函数

在主循环中,我们会不断检查定时器是不是到时间了。
如果超时了,就执行一次IO更新,然后重新设置定时器,等待下一次轮询。

定时器中断配置

首先,我们需要准备一个用于驱动的软件定时器。
在STM32中,实现软件定时器的前提是配置定时器中断。
本示例中,我们将设置定时器每100微秒触发一次中断。

TIM2的时钟源为APB1总线的时钟。

以下是基于84 MHz时钟源配置定时器,使其每100 µs触发一次中断的设置截图:


软件定时器的实现

接下来我们用这个中断来驱动软件定时器。
至于变量的位数,根据你要计的最长时间决定就好。我这里图方便,直接用了一个32位变量。

我们需要两个函数:

  • 一个用于初始化定时器变量
  • 一个在中断中递减它的值
#define USER_TIMER_NUM 1

uint32_t u_timer[USER_TIMER_NUM] = {0};

/**
  * @brief  Timer tick handler for polling-based user timers
  * @param  none
  * @retval none
  * @note   Decrements all user timers and increments the cycle timer
  *         This function is called every time when TIM2 interrupt occurs
  */
void user_timer()
{
    uint8_t i;
    
    for(i = 0; i < USER_TIMER_NUM; i++){
        if(u_timer[i] > 0){
            u_timer[i] = u_timer[i] - 1;
        }
    }
}

另外,我们还需要两个配套函数,一个用于检查定时器是否超时,一个是设置定时器倒计时:

#define TIME_UP 0
#define TIMER_NO0 0
#define TIME_1MS 10

extern void user_timer();
extern uint32_t user_timer_check(uint8_t Timer_No);
extern void user_timer_set(uint8_t Timer_No, uint32_t time);
/**
  * @brief  Get remaining time of the specified user timer
  * @param  timer_no Index of the timer (0 to USER_TIMER_NUM-1)
  * @retval uint32_t Remaining time in units of 100 µs
  * @note   Returns remaining time of specified timer
  */
uint32_t user_timer_check(uint8_t timer_no)
{
    return u_timer[timer_no];
}

/**
  * @brief  Set a specific software timer
  * @param  timer_no Index of the timer (0 to USER_TIMER_NUM-1)
  * @param  time     Countdown time to set (unit: 100 µs)
  * @retval none
  */
void user_timer_set(uint8_t timer_no, uint32_t time)
{
    u_timer[timer_no] = time;
}

最后,需要在TIM2的中断服务函数中调用 user_timer(),并别忘了清除中断标志位:

/**
  * @brief This function handles TIM2 global interrupt.
  */
void TIM2_IRQHandler(void)
{
  /* USER CODE BEGIN TIM2_IRQn 0 */
  if(LL_TIM_IsActiveFlag_UPDATE(TIM2)){
    LL_TIM_ClearFlag_UPDATE(TIM2);
    user_timer();
  }

  /* USER CODE END TIM2_IRQn 0 */
  /* USER CODE BEGIN TIM2_IRQn 1 */

  /* USER CODE END TIM2_IRQn 1 */
}

IO更新函数

接下来就是实际的IO更新函数了。

为了让软件更好写,我在电路设计时特意把MCU的GPIO位顺序,跟上位机看到的一致。
所以在软件上,我就用LL库来直接控制寄存器,没用HAL库(当然这看个人喜好啦)。
电路是低电平有效,所以读取的时候还要做位反转。

#define DI_REG_NUM 1

uint16_t di_reg[DI_REG_NUM] = {0};

/**
  * @brief  IO update
  * @param  none
  * @retval none
  * @note   Update IO data and calculate actual cycle time of this period.
  *         This function is executed by every 1ms
  */
void io_update()
{
    if(user_timer_check(TIMER_NO0) == TIME_UP){
        read_di();
        user_timer_set(TIMER_NO0, TIME_1MS);
    }
}

/**
  * @brief  Read DI from MCU
  * @param  none
  * @retval none
  * @note   chattering eliminator period 1ms,
  *         If previous data and current data is different,
  *         it is defined as chattering.
  *         In that case, do not update DI data.
  */
void read_di()
{
    uint8_t i;

    // Read di port. All di ports are low-active, so bit inversion is necessary
    di_reg[0] = ~(uint8_t)(LL_GPIO_ReadInputPort(GPIOA));
}

由于 io_update() 需要在主循环中调用,请务必在 gpio.h 中声明该函数:

extern void io_update();

主函数

在主函数的 while(1) 循环里加上 io_update(),整个IO轮询流程就算完成了:

while(1){
    io_update();
}

这样就能做到每1ms更新一次IO状态了。

轮询IO的实际应用

轮询IO其实在工业控制场景中很常见,特别是一些对响应时间要求不是特别高的IO板。

它的优点也很明显:

  • 实现简单,不用太多花里胡哨的逻辑
  • 就算是便宜的、性能不高的MCU也能轻松胜任
  • 像传感器、阀门这种设备对响应时间要求不高,没必要频繁更新
  • 而且中断虽然延迟低,但实现稍微复杂一些,轮询在这方面就简单多了。

所以如果你是搞嵌入式开发的,或者是学生准备参加机器人比赛,建议你一定要掌握轮询IO这种最基本的实现方法。后面无论做什么平台,都会用得上!

Discussion