核心外设驱动开发(上):GPIO与LED指示灯控制、SysTick系统定时器、UART串口通信与printf重定向、中断优先级配置与NVIC管理

好,咱们直接进入正题。这一章讲的是嵌入式开发里最基础、也最绕不开的几个外设模块。你想想看,不管你是做充电桩、智能家居还是工业控制器,GPIO、定时器、串口、中断——这四样东西几乎每个项目都会用到。我个人习惯把这四个模块称为「嵌入式开发的四根柱子」,柱子稳了,上面的应用层才能盖得高。

GPIO与LED指示灯控制

GPIO,说白了就是芯片的「手脚」。你可以让它输出高电平去点亮一个LED,也可以读取它的电平来判断按键有没有被按下。在充电桩项目里,LED指示灯用来显示状态:绿色代表空闲,蓝色代表充电中,红色代表故障。

嗯,这里要注意一个坑。我在项目中遇到过好几次,新手工程师配置GPIO时只设置了模式,忘了配置上下拉电阻。结果LED在应该熄灭的时候微微发亮,查了半天才发现是引脚浮空导致的。

GPIO配置的核心步骤:

  1. 使能GPIO外设时钟(RCC_APB2PeriphClockCmd)
  2. 配置GPIO模式(输入/输出/复用/模拟)
  3. 配置输出类型(推挽/开漏)
  4. 配置速度(2MHz/10MHz/50MHz)
  5. 配置上下拉电阻(上拉/下拉/浮空)

来看一个实际的代码示例。这是我在充电桩项目里用的LED驱动初始化函数:

void LED_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    
    // 使能GPIOB时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
    
    // 配置PB0、PB1、PB2为推挽输出
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOB, &GPIO_InitStructure);
    
    // 初始状态全部熄灭
    GPIO_SetBits(GPIOB, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2);
}

你可能会问,为什么速度要选50MHz?其实对于LED这种低频应用,2MHz就足够了。但我习惯统一用50MHz,这样以后复用成其他功能时不用再改配置。当然,如果你对功耗有严格要求,那就按需设置。

小技巧:用位带操作来单独控制某个引脚,比用GPIO_SetBits/GPIO_ResetBits要快得多。我在做充电桩的呼吸灯效果时,就是用位带操作在中断里快速翻转引脚,实现了平滑的PWM效果。

SysTick系统定时器

SysTick是Cortex-M内核自带的24位递减计数器。它最大的好处是——所有Cortex-M芯片都有,代码可以无缝移植。我建议你把它当作系统的「心跳」,用来产生1ms的时基。

为什么是1ms?因为大多数实时任务的时间粒度都在毫秒级。比如充电桩里检测充电枪连接状态,每10ms扫描一次就够了;而LED闪烁,500ms翻转一次。

配置SysTick其实很简单,核心就三步:

  1. 设置重装载值(决定中断频率)
  2. 选择时钟源(AHB/8 或 AHB)
  3. 使能中断并启动计数器

我记得第一次做充电桩项目时,SysTick配置错了时钟源。我选了AHB时钟源,结果72MHz的时钟让SysTick每1.39微秒就中断一次,系统直接卡死。后来查了手册才发现,默认是AHB/8,也就是9MHz。

// SysTick配置函数,产生1ms中断
void SysTick_Init(void)
{
    // 系统时钟72MHz,AHB/8 = 9MHz
    // 重装载值 = 9000000 / 1000 = 9000
    if (SysTick_Config(SystemCoreClock / 1000))
    {
        // 配置失败,进入死循环
        while (1);
    }
}

// SysTick中断服务函数
void SysTick_Handler(void)
{
    static uint32_t tick = 0;
    tick++;
    
    // 每500ms翻转一次LED
    if (tick % 500 == 0)
    {
        GPIO_ToggleBits(GPIOB, GPIO_Pin_0);
    }
}

注意:SysTick_Config函数会默认使能中断。如果你只想用轮询方式读取计数,记得在配置后手动关闭中断:SysTick->CTRL &= ~SysTick_CTRL_TICKINT_Msk。

UART串口通信与printf重定向

串口是嵌入式开发者的「眼睛」。没有串口打印,调试就像蒙着眼睛走路。在充电桩项目里,串口用来输出调试信息、与上位机通信、甚至升级固件。

printf重定向,说白了就是把printf输出的字符串,通过串口发送出去。这样你就可以在代码里用printf("充电电流: %d mA", current)来打印变量,比用USART_SendData一个一个发方便太多了。

实现printf重定向,需要做两件事:

  1. 配置好UART(波特率、数据位、停止位、校验位)
  2. 重写fputc函数,让它调用UART发送一个字节

来看代码:

// UART初始化
void UART_Init(void)
{
    USART_InitTypeDef USART_InitStructure;
    GPIO_InitTypeDef GPIO_InitStructure;
    
    // 使能时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);
    
    // 配置TX(PA9)为复用推挽输出
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    
    // 配置RX(PA10)为浮空输入
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    
    // 配置UART参数
    USART_InitStructure.USART_BaudRate = 115200;
    USART_InitStructure.USART_WordLength = USART_WordLength_8b;
    USART_InitStructure.USART_StopBits = USART_StopBits_1;
    USART_InitStructure.USART_Parity = USART_Parity_No;
    USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
    USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
    USART_Init(USART1, &USART_InitStructure);
    
    USART_Cmd(USART1, ENABLE);
}

// printf重定向
int fputc(int ch, FILE *f)
{
    // 等待发送缓冲区空
    while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
    USART_SendData(USART1, (uint8_t)ch);
    return ch;
}

避坑指南:我曾经在printf重定向后,发现程序一运行就卡死。查了半天,发现是MDK工程里没有勾选「Use MicroLIB」。MicroLIB提供了精简版的printf实现,不勾选的话,fputc不会被调用。另外,如果你用GCC编译器,记得在链接脚本里加上--specs=nano.specs。

中断优先级配置与NVIC管理

中断优先级,说白了就是决定「谁先被服务」。在充电桩项目里,中断优先级配置不好,会导致严重的问题。比如,充电过流保护中断的优先级如果低于某个普通外设中断,那过流发生时可能无法及时响应,后果不堪设想。

NVIC(嵌套向量中断控制器)支持两种优先级分组:抢占优先级和子优先级。抢占优先级高的中断可以打断优先级低的中断,形成嵌套。子优先级则用于同一抢占优先级下的中断排队。

优先级分组 抢占优先级位数 子优先级位数 应用场景
NVIC_PriorityGroup_0 0位 4位 所有中断同级,无嵌套
NVIC_PriorityGroup_1 1位 3位 2级抢占,8级子优先级
NVIC_PriorityGroup_2 2位 2位 4级抢占,4级子优先级
NVIC_PriorityGroup_3 3位 1位 8级抢占,2级子优先级
NVIC_PriorityGroup_4 4位 0位 16级抢占,无子优先级

我个人习惯用NVIC_PriorityGroup_2,也就是4级抢占优先级、4级子优先级。这样既保证了关键中断(比如过流保护)可以打断普通中断,又给同一优先级的中断留了排队空间。

配置中断优先级时,一定要在系统初始化时就设置好分组,中途不要更改。为什么?因为NVIC的分组设置是全局的,一旦更改,所有已配置的中断优先级含义都会变化,容易引发不可预知的行为。

// 中断优先级配置示例
void NVIC_Configuration(void)
{
    NVIC_InitTypeDef NVIC_InitStructure;
    
    // 设置优先级分组为4级抢占,4级子优先级
    NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
    
    // 配置USART1中断
    NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;  // 抢占优先级1
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;         // 子优先级0
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);
    
    // 配置SysTick中断(抢占优先级最低)
    NVIC_InitStructure.NVIC_IRQChannel = SysTick_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 3;  // 抢占优先级3
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 3;         // 子优先级3
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);
}

重要提醒:中断服务函数要短小精悍,不要在中断里做复杂运算或调用printf。我在项目中遇到过,有人在UART中断里直接调用printf,结果printf又触发UART中断,形成了递归调用,栈直接爆了。正确的做法是:中断里只做标志位设置或数据接收,具体处理放到主循环里做。

好了,这一章的内容就到这里。GPIO、SysTick、UART、NVIC——这四个模块是嵌入式开发的基石。你把这些吃透了,后面做充电桩的通信协议、状态机、故障处理就会轻松很多。下一章我们讲定时器、PWM和ADC,这些是控制充电功率的关键。