2. HAL库基础架构:分层设计、文件结构、API命名规则与回调机制
好,咱们进入第二章。这一章我打算把HAL库的骨架给你拆开看看。很多初学者拿到STM32CubeIDE生成的工程,面对一堆文件夹和文件,第一反应是懵的——"我该改哪个文件?""这个函数是干什么的?"
说实话,我当年刚接触HAL库时也踩过这个坑。有一次调试一个I2C设备,死活通信不上,后来发现是我在stm32f1xx_hal_i2c.c里改了个宏定义,结果编译时链接了stm32f1xx_hal_i2c_ex.c里的同名函数……嗯,从那以后,我养成了一个习惯:先搞清楚文件结构,再动手写代码。
2.1 HAL库的分层设计
HAL库不是一坨代码堆在一起的。它分了清晰的层次,每一层各司其职。我习惯把它画成下面这张图:
这张图你得记住。从上往下看:
- 应用层:你的
main.c、业务逻辑文件。这一层只调用HAL API,不直接操作寄存器。 - HAL API层:封装好的外设驱动接口。比如
HAL_UART_Transmit(),你传个句柄和数据指针就行。 - LL层:低层驱动,直接操作寄存器。我一般在需要极致性能或特殊时序时才用LL层。
- CMSIS层:ARM官方的内核抽象层。提供
NVIC_EnableIRQ()、SysTick_Config()这些基础函数。 - 硬件层:就是芯片本身了。
我的习惯: 项目初期先用HAL API快速验证功能。如果发现性能瓶颈,再局部替换为LL层代码。别一上来就撸寄存器,除非你特别清楚自己在干什么。
2.2 文件结构:工程里到底有哪些文件?
用STM32CubeIDE生成一个工程后,你会看到类似下面的目录结构。我挑几个关键的说:
| 文件夹/文件 | 作用 | 你该不该改? |
|---|---|---|
Core/Inc |
用户头文件(main.h, gpio.h, usart.h等) | 可以改,但别动自动生成的部分 |
Core/Src |
用户源文件(main.c, stm32f1xx_it.c等) | 主要修改区域 |
Drivers/STM32F1xx_HAL_Driver/Inc |
HAL库头文件(stm32f1xx_hal.h, stm32f1xx_hal_uart.h等) | 不要改——除非你清楚后果 |
Drivers/STM32F1xx_HAL_Driver/Src |
HAL库源文件(stm32f1xx_hal_uart.c等) | 不要改——升级库时会覆盖 |
Drivers/CMSIS |
CMSIS内核抽象层 | 基本不动 |
.ioc文件 |
CubeMX图形配置工程文件 | 通过CubeMX修改,别手动编辑 |
我曾经犯过的错: 刚用HAL库时,为了修一个bug,我直接在
stm32f1xx_hal_uart.c里改了内部函数。后来升级库版本,一覆盖,所有修改全丢了。正确的做法是:在用户文件里重写回调函数,或者用__weak修饰符覆盖默认实现。
2.3 API命名规则:看名字就知道怎么用
HAL库的API命名非常规范。你只要掌握了规律,看到函数名就能猜出七八分功能。我总结了一个表格:
| 模式 | 命名格式 | 示例 | 说明 |
|---|---|---|---|
| 轮询 | HAL_<PERIPH>_<Action>() |
HAL_UART_Transmit() |
阻塞执行,直到完成或超时 |
| 中断 | HAL_<PERIPH>_<Action>_IT() |
HAL_UART_Transmit_IT() |
中断方式,不阻塞 |
| DMA | HAL_<PERIPH>_<Action>_DMA() |
HAL_UART_Transmit_DMA() |
DMA传输,CPU负担最小 |
| 初始化 | HAL_<PERIPH>_Init() |
HAL_UART_Init() |
配置外设参数 |
| 去初始化 | HAL_<PERIPH>_DeInit() |
HAL_UART_DeInit() |
恢复外设到复位状态 |
| MSP初始化 | HAL_<PERIPH>_MspInit() |
HAL_UART_MspInit() |
配置GPIO、时钟、NVIC——用户可重写 |
你看,规律很明显:HAL_前缀 + 外设名 + 动作。后缀_IT表示中断,_DMA表示DMA。没有后缀就是轮询模式。
一个实用技巧: 当你需要快速查找某个外设的API时,在IDE里输入
HAL_UART_,IDE会自动弹出所有相关函数。我经常用这招,比翻手册快多了。
2.4 回调机制:HAL库的"钩子"是怎么工作的?
回调机制是HAL库的精髓之一。说白了,就是HAL库在特定事件发生时,调用你预先写好的函数。你不用去改HAL库的源码,只需要实现这些回调函数就行。
我画个流程图帮你理解:
流程其实很简单:
- 硬件触发中断
- CPU跳转到中断向量表
- 执行HAL库的中断处理函数(比如
HAL_UART_IRQHandler()) - HAL库判断中断类型,调用对应的弱定义(__weak)回调函数
- 你的回调函数被执行
关键点在于:HAL库提供的回调函数都是__weak修饰的。这意味着如果你在用户代码里定义了同名的函数,编译器会优先使用你的版本,而不是HAL库的默认空函数。
举个例子: 默认的
HAL_UART_RxCpltCallback()是个空函数。你在main.c里写一个同名函数,里面放上处理接收数据的代码。这样UART每收到一个字节,你的代码就会自动执行。
常见的回调函数有这些:
| 回调函数 | 触发时机 | 典型用途 |
|---|---|---|
HAL_UART_RxCpltCallback() |
UART接收完成 | 处理接收到的数据 |
HAL_UART_TxCpltCallback() |
UART发送完成 | 释放发送缓冲区 |
HAL_GPIO_EXTI_Callback() |
外部中断触发 | 按键检测、边沿触发事件 |
HAL_TIM_PeriodElapsedCallback() |
定时器溢出 | 周期性任务调度 |
HAL_ADC_ConvCpltCallback() |
ADC转换完成 | 读取转换结果 |
我的调试习惯: 在回调函数里加个GPIO翻转,用示波器看波形。比如在
HAL_UART_RxCpltCallback()里翻转一个测试引脚,就能直观看到回调是否被触发、触发频率是多少。这比加打印语句靠谱多了,尤其在高频中断里。
2.5 小结:记住这三件事
这一章内容不少,但你只需要记住三件事:
- 分层结构:应用层 → HAL API层 → LL层 → CMSIS层 → 硬件。调用从上到下,别跨层操作。
- 文件别乱改:HAL库源文件是只读的。你的修改应该放在用户文件里,或者通过重写
__weak回调来实现。 - 回调是钩子:HAL库帮你处理了中断的脏活累活,你只需要在回调函数里写业务逻辑。这是HAL库设计者留给你的"后门"。