3. 嵌入式软件架构分层概述
说到嵌入式移植,BSP 是绕不开的核心。很多人把 BSP 理解成「一堆驱动代码」,其实没那么简单。我个人习惯把 BSP 看作硬件和软件之间的契约层——它定义了操作系统或应用层该以什么方式访问硬件,硬件又该给上层提供哪些能力。
这一章,我们就把 BSP 拆开来看。从组成、职责,到和 HAL 的关系,再到启动代码、链接脚本、时钟树和电源管理。这些都是我这些年移植项目里踩坑最多的地方。
3.1 BSP 的组成与职责
BSP 的全称是 Board Support Package,板级支持包。它不是一个单一的库,而是一组代码和配置文件的集合。它的核心职责就两条:屏蔽硬件差异,提供统一接口。
我一般把 BSP 分成下面几个模块:
| 模块 | 职责 | 典型文件 |
|---|---|---|
| 启动代码 | 芯片上电后的第一条指令,初始化堆栈、中断向量表、C 运行时环境 | startup_xxx.s, reset_handler.c |
| 链接脚本 | 定义内存布局,告诉链接器代码段、数据段、堆栈放在哪里 | xxx.ld, xxx.scatter |
| 时钟与电源管理 | 配置系统时钟源、PLL、分频器,以及低功耗模式切换 | clk_config.c, pmu.c |
| 外设驱动 | UART、SPI、I2C、GPIO 等基础外设的初始化与操作 | uart.c, gpio.c |
| 中断控制器 | NVIC/GIC 的配置,中断优先级、使能、向量重定向 | irq_handler.c, vector_table.c |
| 板级配置 | 板子特有的引脚复用、外设映射、内存映射 | board.h, pinmux.c |
你想想看,如果每次换一块板子都要重写整个操作系统,那项目就没法做了。BSP 就是那个「换板子只改 BSP,上层代码不动」的关键。
核心原则:BSP 的接口设计要足够通用,不能把某个芯片的特殊寄存器操作暴露给上层。我见过最糟糕的代码,是在应用层直接写寄存器地址——换芯片就全废了。
3.2 BSP 与 HAL 的关系
这个问题经常有人搞混。BSP 和 HAL 到底有什么区别?
说白了,HAL(硬件抽象层)是芯片厂商提供的,它把某个系列芯片的寄存器操作封装成 API。比如 STM32 的 HAL 库,你调用 HAL_UART_Transmit,它帮你操作 USART 的 DR 寄存器、状态寄存器。
而 BSP 是板级开发者写的,它基于 HAL 或者直接基于寄存器,完成板子级别的初始化。比如你的板子上 UART2 接了 GPS 模块,UART1 接了调试串口,BSP 里就要把这两个外设的引脚、时钟、中断都配好。
我画了一张图,帮你理清它们的关系:
从图上你能看到,BSP 在 OS 之下,HAL 之上。BSP 可以调用 HAL,也可以绕过 HAL 直接操作寄存器。为什么?因为有些板级特有的初始化,HAL 没提供接口,或者 HAL 的封装太臃肿,性能不达标。
我的建议:能用 HAL 就用 HAL,别自己造轮子。但遇到性能瓶颈或者 HAL 不支持的功能,果断直接操作寄存器。我在一个音频项目中,HAL 的 I2S 初始化多花了 200 个时钟周期,直接写寄存器就解决了。
3.3 启动代码与链接脚本
启动代码和链接脚本,是 BSP 里最容易被忽视、但出问题最致命的部分。芯片上电后,第一条指令从哪里执行?堆栈指针怎么初始化?全局变量什么时候赋值?这些全由启动代码和链接脚本决定。
3.4.1 启动代码的核心流程
以 ARM Cortex-M 为例,启动代码一般做这几件事:
- 设置堆栈指针:从向量表的第一个字加载 SP 初始值。
- 跳转 Reset_Handler:向量表的第二个字是 Reset_Handler 入口。
- 初始化 .bss 段:清零未初始化的全局变量区域。
- 初始化 .data 段:从 Flash 拷贝已初始化的全局变量到 RAM。
- 调用 SystemInit:配置系统时钟、PLL、Flash 等待周期。
- 跳转 main:进入 C 世界。
我曾经在一个项目里,因为 .bss 段没清零,导致一个全局标志位在上电时是随机值,程序跑飞了三天才定位到问题。嗯,从那以后我每次移植都会在启动代码里加一段 .bss 清零的断言检查。
; 典型的 Cortex-M 启动代码片段 (startup_xxx.s)
AREA RESET, DATA, READONLY
EXPORT __Vectors
EXPORT __Vectors_End
EXPORT __Vectors_Size
__Vectors DCD __initial_sp ; 栈顶地址
DCD Reset_Handler ; 复位向量
DCD NMI_Handler ; NMI
DCD HardFault_Handler ; 硬错误
; ... 其他中断向量 ...
AREA |.text|, CODE, READONLY
Reset_Handler PROC
EXPORT Reset_Handler
; 初始化 .bss 段
LDR R0, =__bss_start
LDR R1, =__bss_end
MOVS R2, #0
bss_loop CMP R0, R1
ITT LT
STRLT R2, [R0], #4
BLT bss_loop
; 初始化 .data 段
LDR R0, =__data_start
LDR R1, =__data_end
LDR R2, =__data_load
data_loop CMP R0, R1
ITTT LT
LDRLT R3, [R2], #4
STRLT R3, [R0], #4
BLT data_loop
; 调用 SystemInit
BL SystemInit
; 跳转 main
BL main
B .
ENDP
3.4.2 链接脚本的内存布局
链接脚本定义了代码、数据、堆栈在内存中的位置。不同的芯片,Flash 和 RAM 的地址范围不同,链接脚本必须对应修改。
我一般把链接脚本分成几个关键区域:
| 段名 | 存储位置 | 内容 |
|---|---|---|
| .text | Flash (只读) | 代码、常量、向量表 |
| .rodata | Flash (只读) | 只读数据,如字符串常量 |
| .data | RAM (读写),初始值在 Flash | 已初始化的全局变量 |
| .bss | RAM (读写) | 未初始化的全局变量 |
| heap | RAM | 动态内存分配区 |
| stack | RAM | 栈空间 |
/* 典型的 ARM GCC 链接脚本 (stm32f407.ld) */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text :
{
KEEP(*(.isr_vector)) /* 中断向量表 */
*(.text*) /* 代码 */
*(.rodata*) /* 只读数据 */
. = ALIGN(4);
_etext = .;
} > FLASH
.data : AT(ADDR(.text) + SIZEOF(.text))
{
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM
.bss :
{
_sbss = .;
*(.bss*)
. = ALIGN(4);
_ebss = .;
} > RAM
/* 堆栈定义 */
.heap (NOLOAD) :
{
. = ALIGN(8);
_heap_start = .;
. = . + 0x4000; /* 16KB 堆 */
_heap_end = .;
} > RAM
.stack (NOLOAD) :
{
. = ALIGN(8);
_stack_start = .;
. = . + 0x2000; /* 8KB 栈 */
_stack_end = .;
} > RAM
}
注意:链接脚本里的 ORIGIN 和 LENGTH 必须和芯片数据手册一致。我曾经见过有人把 STM32F103 的链接脚本直接用在 F407 上,Flash 地址从 0x08000000 变成了 0x08010000,结果程序死活跑不起来。
3.4 时钟树与电源管理移植
时钟树是 BSP 移植里最繁琐的部分。不同芯片的时钟源、PLL 倍频系数、分频器配置都不一样。电源管理则关系到低功耗模式的切换,在电池供电的设备里尤其重要。
3.4.1 时钟树配置
时钟树的核心是:选源、倍频、分频、输出。以 STM32F4 为例,典型的时钟树配置流程如下:
- 选择时钟源:HSI(内部高速)、HSE(外部晶振)、PLL。
- 配置 PLL:输入频率 × N / M,得到系统时钟。
- 配置 AHB、APB1、APB2 分频器。
- 配置 Flash 等待周期(跟系统时钟频率相关)。
- 切换系统时钟到新配置。
我习惯把时钟配置写成一个独立的函数,方便在不同板子间复用。每次移植只需要改几个宏定义:
/* 时钟配置示例 (stm32f4xx_clk.c) */
#define HSE_VALUE 8000000U /* 外部晶振 8MHz */
#define PLL_M 8
#define PLL_N 336
#define PLL_P 2 /* 系统时钟 = 8/8*336/2 = 168MHz */
#define PLL_Q 7 /* USB/SDIO 时钟 = 8/8*336/7 = 48MHz */
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.PLL.PLLState = RCC_PLL_ON;
RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE;
RCC_OscInitStruct.PLL.PLLM = PLL_M;
RCC_OscInitStruct.PLL.PLLN = PLL_N;
RCC_OscInitStruct.PLL.PLLP = PLL_P;
RCC_OscInitStruct.PLL.PLLQ = PLL_Q;
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 = 168MHz */
RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV4; /* APB1 = 42MHz */
RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV2; /* APB2 = 84MHz */
HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_5);
}
避坑指南:配置时钟时,一定要先确认 Flash 等待周期。系统时钟跑 168MHz,Flash 等待周期设成 3,读指令就会出错。我遇到过最诡异的情况是程序偶尔跑飞,最后发现是等待周期设少了。
3.4.2 电源管理移植
电源管理移植主要涉及低功耗模式。常见的模式有:
- Sleep 模式:CPU 停止,外设继续工作,唤醒最快。
- Stop 模式:CPU 和外设都停止,RAM 保持,唤醒稍慢。
- Standby 模式:几乎全部断电,只有备份域供电,唤醒最慢。
移植时要注意几个关键点:
- 不同芯片的低功耗模式名称和寄存器可能不同,但逻辑类似。
- 进入低功耗前,要关闭不需要的外设时钟,否则功耗降不下来。
- 唤醒源(如 RTC 闹钟、外部中断)的配置要跟 BSP 的 GPIO 配置一致。
我曾经在一个 IoT 项目里,设备待机电流标称 10μA,实测 200μA。查了两天,发现是 GPIO 没配置成模拟输入模式,漏电了。嗯,从那以后我每次做低功耗移植,都会用电流表逐模块排查。
/* 进入 Stop 模式示例 */
void Enter_StopMode(void)
{
/* 关闭不需要的外设时钟 */
__HAL_RCC_GPIOA_CLK_DISABLE();
__HAL_RCC_GPIOB_CLK_DISABLE();
/* ... 其他外设 */
/* 配置唤醒源:RTC 闹钟 */
HAL_RTC_SetAlarm_IT(&hrtc, &sAlarm, RTC_FORMAT_BIN);
/* 进入 Stop 模式 */
HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI);
}
总结一下:BSP 构建的核心是「分层」和「抽象」。启动代码和链接脚本是地基,时钟树和电源管理是骨架,HAL 是工具。把这几个部分理清楚,移植任何芯片都不会慌。