3. 硬件初始化:时钟系统初始化(PLL配置)、GPIO初始化、中断向量表设置、堆栈指针设置

好,咱们接着往下走。上一章我们把启动流程的脉络理清了,这一章要动真格的了——硬件初始化。

说白了,芯片上电后就像个刚出生的婴儿,啥都不会。你得手把手教它:时钟怎么跑、引脚怎么用、中断来了找谁、程序栈放哪儿。这四个点,一个都不能少。

我个人习惯把硬件初始化分成四步走:时钟 → GPIO → 中断向量表 → 堆栈指针。顺序别搞反了,尤其是时钟,它是所有外设的“心脏”。

3.1 时钟系统初始化(PLL配置)

时钟系统,我愿称之为整个系统的“命脉”。没有时钟,CPU就是一块死硅。但光有还不行,你得让它跑得又快又稳。

大多数MCU上电后默认用的是内部低速振荡器(比如8MHz的HSI),这玩意儿精度一般,跑个点灯还行,跑USB、以太网?想都别想。所以我们要做的是:配置PLL,把时钟倍频到目标频率

核心思路:外部晶振(HSE)→ PLL倍频 → 系统时钟(SYSCLK)→ 总线时钟(AHB/APB)

我在项目中遇到过一件事:有个同事把PLL配置错了,芯片直接“死机”,连调试器都连不上。后来发现是PLL的倍频系数超出了芯片允许范围。嗯,这里要注意——PLL的输出频率不能超过芯片手册规定的最大值

来看一个典型的PLL配置代码(以STM32F4为例):

void SystemClock_Config(void)
{
    // 1. 使能HSE外部晶振
    RCC->CR |= RCC_CR_HSEON;
    while(!(RCC->CR & RCC_CR_HSERDY));  // 等待HSE就绪

    // 2. 配置PLL参数
    RCC->PLLCFGR = (8 << 24) |          // PLL_Q = 8(用于USB等外设)
                   (336 << 6) |          // PLL_N = 336(倍频系数)
                   (4 << 0);             // PLL_M = 4(分频系数)
    // 最终PLL输出 = 8MHz(HSE) / 4 * 336 / 8 = 84MHz

    // 3. 使能PLL并等待锁定
    RCC->CR |= RCC_CR_PLLON;
    while(!(RCC->CR & RCC_CR_PLLRDY));

    // 4. 配置AHB/APB预分频
    RCC->CFGR |= RCC_CFGR_HPRE_DIV1;    // AHB = SYSCLK
    RCC->CFGR |= RCC_CFGR_PPRE1_DIV2;   // APB1 = AHB/2
    RCC->CFGR |= RCC_CFGR_PPRE2_DIV1;   // APB2 = AHB

    // 5. 切换系统时钟为PLL
    RCC->CFGR |= RCC_CFGR_SW_PLL;
    while((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);
}

小技巧:配置PLL时,我建议先把所有时钟源都关掉,再逐个使能。这样可以避免时钟切换时的毛刺。另外,别忘了配置Flash等待周期——频率高了,Flash读取速度跟不上,程序会跑飞。

3.2 GPIO初始化

时钟搞定了,接下来就是GPIO。你想想看,芯片和外界的交互全靠这些引脚。但GPIO不是上电就能用的,你得告诉它:你是输入还是输出?要不要上拉?速度多快?

GPIO初始化的核心就三件事:使能时钟、配置模式、设置初始电平

我曾经犯过一个低级错误:GPIO初始化时忘了使能对应的外设时钟,结果引脚死活没反应。查了半天,最后发现是RCC那一步漏了。所以我现在写代码,第一件事就是检查时钟使能。

来看一个标准的GPIO初始化流程:

void GPIO_Init(void)
{
    // 1. 使能GPIO时钟(这一步最容易忘!)
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;

    // 2. 配置引脚模式
    GPIOA->MODER &= ~(0x3 << (5 * 2));  // 清除PA5原有配置
    GPIOA->MODER |= (0x1 << (5 * 2));   // 设置为输出模式

    // 3. 配置输出类型(推挽/开漏)
    GPIOA->OTYPER &= ~(1 << 5);         // 推挽输出

    // 4. 配置速度
    GPIOA->OSPEEDR |= (0x2 << (5 * 2)); // 高速模式

    // 5. 配置上下拉
    GPIOA->PUPDR &= ~(0x3 << (5 * 2)); // 无上下拉

    // 6. 设置初始电平(输出高)
    GPIOA->BSRR = (1 << 5);
}

警告:配置GPIO时,一定要先清除原有配置再写入新值。否则可能会因为复位后的默认值导致意想不到的行为。我见过有人直接用赋值操作,结果把相邻引脚也改了。

3.3 中断向量表设置

中断向量表,说白了就是一张“紧急联系人名单”。芯片遇到中断时,会查这张表,找到对应的处理函数地址,然后跳过去执行。

默认情况下,向量表放在Flash的起始地址(0x08000000)。但有时候我们需要把它搬到RAM里——比如做IAP在线升级时,要动态修改中断处理函数。

我记得第一次做IAP项目时,中断向量表没重定位,结果跳转到APP后,一按按键就死机。后来才发现,中断还是指向了Bootloader的向量表。

向量表重定位的代码很简单:

// 设置向量表偏移(以STM32为例)
SCB->VTOR = (uint32_t)APP_ADDRESS & 0x1FFFFF80;

这里有个坑:VTOR寄存器的地址必须按128字节对齐。为什么?因为向量表里每个条目是4字节,一共32个异常向量,正好128字节。嗯,这是ARM架构的规定,咱们照做就行。

避坑指南:我曾经在某个项目里,把向量表地址设成了0x08001000,结果发现中断响应不对。查了半天手册才发现,这个地址没有128字节对齐。从那以后,我每次写VTOR都会加个对齐宏:#define ALIGN_128(x) ((x) & ~0x7F)

3.4 堆栈指针设置

堆栈指针(SP),就是CPU用来记录栈顶位置的寄存器。程序里的局部变量、函数调用时的返回地址,全都要靠栈来管理。

芯片上电后,SP的值是从哪里来的?答案是:从Flash的0地址读取。没错,向量表的第一个条目就是栈顶地址。所以你在链接脚本里定义的栈大小和起始地址,最终会通过启动文件写入到0地址。

来看启动文件里是怎么做的:

__initial_sp EQU 0x20010000  ; 栈顶地址

AREA RESET, DATA, READONLY
    DCD __initial_sp          ; 向量表第一个条目:栈顶地址
    DCD Reset_Handler         ; 第二个条目:复位中断入口

你可能会问:为什么栈顶地址要放在第一个?因为CPU上电后,硬件会自动把SP初始化为这个值。如果这里写错了,程序连第一条指令都执行不了。

个人经验:栈的大小怎么定?我一般按实际需求再加30%的余量。比如你的函数调用深度是1KB,我就设1.3KB。太小了会栈溢出,太大了浪费RAM。你可以用编译器的栈分析工具来精确计算。

另外,别忘了在C代码里重新设置SP的情况——比如从Bootloader跳转到APP时,APP的栈可能和Bootloader不一样。这时候需要手动设置:

// 手动设置堆栈指针
__asm void SetSP(uint32_t addr)
{
    MSR MSP, R0
    BX LR
}

// 跳转到APP前调用
SetSP(*(uint32_t*)APP_ADDRESS);

小结

好了,硬件初始化的四个核心步骤就讲完了。总结一下:

  • 时钟系统:先配PLL,再配总线分频,别忘了Flash等待周期
  • GPIO:先使能时钟,再配模式,最后设电平
  • 中断向量表:默认在Flash,重定位时注意128字节对齐
  • 堆栈指针:启动文件里定义好,跳转时记得重新设置

下一章,我们会把这些初始化代码整合到一起,写一个完整的启动流程。到时候你会发现,前面这些零散的知识点,其实都是环环相扣的。