3、STM32最小系统搭建:时钟树配置、GPIO初始化、串口调试、LED指示灯驱动

好,咱们正式开始动手了。

这一章,我带你搭建STM32的最小系统。说白了,就是让芯片能跑起来,能跟你对话,还能给你点个灯看看。

很多新手一上来就急着写业务逻辑,结果灯不亮、串口没输出,查半天发现是时钟没配好。嗯,我当年也干过这种事。

3.1 时钟树配置——芯片的心脏跳动

STM32是个复杂的家伙,它内部有好几个时钟源。你得告诉它:用哪个时钟、跑多快、分频多少。这就是时钟树配置。

常用的时钟源有这些:

  • HSI(高速内部时钟):8MHz,芯片自带,精度一般。我一般只在调试初期用一下。
  • HSE(高速外部时钟):通常接8MHz晶振,精度高,稳定。项目量产我必用这个。
  • PLL(锁相环):把时钟倍频上去。比如HSE 8MHz,通过PLL倍频到72MHz或168MHz。

我个人习惯,只要不是成本敏感到极致,一律上外部晶振。为什么?因为HSI受温度影响大,串口通信容易丢包。我在一个农业大棚项目里吃过这个亏,夏天高温时数据乱飞,换了HSE立马解决。

核心配置思路:

选择HSE → 配置PLL倍频系数 → 设置系统时钟源为PLL → 配置AHB、APB1、APB2分频器。

来看一段HAL库的配置代码,我用的是STM32F103,72MHz主频:

void SystemClock_Config(void)
{
    RCC_OscInitTypeDef RCC_OscInitStruct = {0};
    RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};

    // 配置HSE振荡器,使能PLL
    RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE;
    RCC_OscInitStruct.HSEState = RCC_HSE_ON;
    RCC_OscInitStruct.HSEPredivValue = RCC_HSE_PREDIV_DIV1;
    RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
    RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE;
    RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9;  // 8MHz * 9 = 72MHz
    HAL_RCC_OscConfig(&RCC_OscInitStruct);

    // 配置系统时钟、AHB、APB1、APB2分频
    RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK
                                | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2;
    RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
    RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;     // HCLK = 72MHz
    RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2;      // APB1 = 36MHz
    RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;      // APB2 = 72MHz
    HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2);
}

小提示:APB1最高只能到36MHz,APB2可以到72MHz。串口、I2C这些外设挂在APB1上,别超频了。我曾经把APB1设成72MHz,结果I2C死活不工作,查了两天。

3.2 GPIO初始化——让芯片的脚有灵魂

时钟配好了,芯片活了。但它的引脚还处于高阻态,啥也干不了。你得初始化GPIO,告诉它:这个脚是输出还是输入?推挽还是开漏?速度多快?

GPIO的配置模式,我列个表,你一看就明白:

模式 用途 典型场景
推挽输出 输出高低电平 LED、蜂鸣器、继电器
开漏输出 需要外部上拉,实现线与 I2C总线、电平转换
浮空输入 电平不确定,需外部驱动 按键(配合外部上拉)
上拉/下拉输入 内部电阻固定电平 按键(省外部电阻)
复用功能 引脚交给外设控制 串口TX/RX、PWM输出

拿LED来说,我们一般用推挽输出。为什么?因为LED需要明确的亮和灭,推挽输出能提供足够的驱动电流。

看代码,初始化一个LED引脚(PA0):

void LED_GPIO_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};

    __HAL_RCC_GPIOA_CLK_ENABLE();  // 别忘了开时钟!我经常忘

    GPIO_InitStruct.Pin = GPIO_PIN_0;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;     // 推挽输出
    GPIO_InitStruct.Pull = GPIO_NOPULL;             // 不需要上下拉
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;    // LED切换慢,低频即可
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET);  // 初始灭
}

注意:GPIO的速度不是越高越好。高速意味着更陡的边沿、更大的EMI干扰。LED这种低频设备,用LOW就够了。我见过有人把所有引脚都配成HIGH,结果板子成了一个小电台。

3.3 串口调试——芯片跟你说话了

时钟有了,灯能亮了。但芯片在想什么,你不知道。这时候就需要串口了。

串口调试,说白了就是让芯片通过TX引脚把数据发出来,你在电脑上用串口助手看。这是嵌入式开发最常用的调试手段,没有之一。

配置串口,需要做这几件事:

  1. 使能串口和对应GPIO的时钟
  2. 配置TX为复用推挽输出,RX为浮空输入(或上拉输入)
  3. 设置波特率、数据位、停止位、校验位
  4. 使能串口

我一般用115200波特率,8位数据,1位停止位,无校验。这个组合最通用,也最稳定。

来看USART1的初始化代码(PA9=TX, PA10=RX):

void UART1_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    UART_HandleTypeDef huart1;

    __HAL_RCC_USART1_CLK_ENABLE();
    __HAL_RCC_GPIOA_CLK_ENABLE();

    // TX: PA9, 复用推挽输出
    GPIO_InitStruct.Pin = GPIO_PIN_9;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    // RX: PA10, 浮空输入
    GPIO_InitStruct.Pin = GPIO_PIN_10;
    GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    // 串口参数配置
    huart1.Instance = USART1;
    huart1.Init.BaudRate = 115200;
    huart1.Init.WordLength = UART_WORDLENGTH_8B;
    huart1.Init.StopBits = UART_STOPBITS_1;
    huart1.Init.Parity = UART_PARITY_NONE;
    huart1.Init.Mode = UART_MODE_TX_RX;
    huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
    HAL_UART_Init(&huart1);
}

调试技巧:初始化完成后,先发一个"Hello World"测试一下。如果收不到,先检查波特率是否匹配,再用示波器看TX引脚有没有波形。没有波形?八成是时钟没配好或者GPIO模式错了。

3.4 LED指示灯驱动——最朴素的反馈

串口能打印信息了,但有时候你不想开电脑。比如在现场调试,手边只有一块板子。这时候LED就是最好的伙伴。

LED驱动其实很简单,就是控制GPIO的高低电平。但怎么用得好,有讲究。

我常用的LED指示方案:

  • 系统运行指示:500ms闪烁一次,表示系统活着
  • 通信指示:收到数据时快速闪一下
  • 错误指示:长亮或特定频率闪烁

来看一个简单的LED闪烁函数,放在主循环里调用:

void LED_Blink(void)
{
    static uint32_t last_tick = 0;
    uint32_t now = HAL_GetTick();

    if (now - last_tick > 500)  // 500ms切换一次
    {
        HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_0);
        last_tick = now;
    }
}

这里用了HAL_GetTick()做非阻塞延时。千万别用HAL_Delay()做闪烁,否则整个系统都被卡住了。你想想看,如果延时的时候串口来数据了,你根本收不到。

避坑指南:

我曾经在一个项目里,用HAL_Delay(500)做LED闪烁,结果串口接收中断响应不及时,数据频繁丢失。后来改成定时器或系统滴答计时,问题就解决了。记住:主循环里不要用阻塞延时。

3.5 整合测试——让一切跑起来

好,现在我们把时钟、GPIO、串口、LED都配好了。来写个main函数,验证一下:

int main(void)
{
    HAL_Init();
    SystemClock_Config();   // 配置时钟
    LED_GPIO_Init();        // 初始化LED
    UART1_Init();           // 初始化串口

    printf("System Started!\r\n");  // 串口打印

    while (1)
    {
        LED_Blink();        // LED闪烁
        // 其他任务...
    }
}

下载程序后,你应该看到:

  • LED以500ms的频率闪烁
  • 串口助手收到"System Started!"

如果灯不亮?先查硬件连接,再查GPIO时钟有没有使能。如果串口没输出?检查波特率、TX引脚配置、还有你的USB转串口模块有没有接对。

嗯,这一章的内容就到这里。最小系统搭好了,后面的事情就好办了。下一章,我们开始接传感器,让温室真正"感知"环境。