| 编辑推荐: |
本文主要介绍了在嵌入式项目中,如何设计一个真正能跨平台复用的HAL层。 不讲空泛的分层理论,直接从实际问题出发,讲设计思路、接口定义、工程组织和常见的坑。希望对您的学习有所帮助。
本文来自于微信公众号一枚嵌入式码农,由火龙果软件Alice编辑、推荐。
|
|
做嵌入式开发,换芯片是常态。
供应链缺货、成本压缩、客户指定平台,不管什么原因,你迟早会碰到"把代码从一颗MCU移植到另一颗MCU"这件事。而每到这个时候,大部分工程师的反应都差不多:打开工程,全局搜索寄存器名,开始逐行替换,然后陷入漫长的编译-报错-修复循环。
我经历过好几次这样的移植。印象最深的一次是把一个STM32F4的项目迁移到GD32E5,两家芯片号称"兼容",结果光串口和SPI的驱动适配就花了将近一周。业务逻辑里到处都是
HAL_UART_Transmit、HAL_SPI_TransmitReceive 的直接调用,改完一处漏一处,每天都在灭火。
后来反思,问题根源不在芯片差异大,而在于代码里压根没有一层真正的硬件抽象。ST的HAL库虽然叫"HAL",但它只是ST自己的一套驱动封装,并不等于你项目的HAL层。你的业务代码直接调用它,本质上就是把业务绑死在了ST的生态上。
这篇文章,我想系统地聊一下:在嵌入式项目中,如何设计一个真正能跨平台复用的HAL层。 不讲空泛的分层理论,直接从实际问题出发,讲设计思路、接口定义、工程组织和常见的坑。
一、先搞清楚一件事:厂商HAL库 ≠ 你的HAL层
很多人一听"HAL层",第一反应就是STM32的HAL库。但这里需要明确一个概念上的区别:
厂商HAL库是芯片厂商提供的驱动封装,目的是让你不用直接操作寄存器。ST有HAL库,NXP有SDK,瑞萨有FSP,兆易有GD32标准库,每家的API都不一样,数据结构也不一样。
你的HAL层是你自己在项目中定义的一层抽象接口,它位于厂商驱动之上、业务逻辑之下。它的目的是:让业务代码不知道也不关心底层用的是哪家的芯片和哪套驱动库。
两者的关系是这样的:
上面这张图是整篇文章的核心思路。你的HAL层就像一个"翻译官",业务代码说"把这串数据从串口发出去",翻译官负责把这句话翻译成STM32能听懂的
HAL_UART_Transmit,或者GD32能听懂的 usart_data_transmit。
换平台的时候,你只需要换一个"翻译官",业务代码原封不动。
这就是自定义HAL层的价值所在。
二、HAL层接口设计的几个核心原则
在动手写代码之前,先聊几个设计原则。这些原则不是什么高深理论,都是我在实际项目中反复踩坑之后沉淀下来的经验。
原则一:接口按功能定义,不按外设定义
很多人设计HAL层的思路是"MCU有什么外设,我就封装什么外设"。这没有错,但还不够。
更好的做法是从业务需求出发:你的系统需要什么能力?需要"发送一帧数据"、"读一个传感器"、"控制一个LED",这些是功能需求,至于底层走的是UART还是SPI、用的是GPIO直驱还是I2C扩展芯片,那是实现细节。
举个例子:你的项目用I2C接一个温度传感器,后来换成了SPI接口的传感器。如果你的HAL层是按外设定义的(hal_i2c_read),业务层就得跟着改。但如果你按功能定义(hal_sensor_read_temp),业务层完全不受影响。
当然,对于通用外设(GPIO、UART、SPI、I2C、Timer),按外设封装是合理的,因为它们本身就是通用能力。关键是不要在接口中泄露平台特有的细节。
原则二:头文件里不出现任何平台相关的类型
这条规则非常重要,但被违反得最多。
// ✗ 错误示范:头文件暴露了STM32的类型 #include "stm32f4xx_hal.h" void hal_uart_init(UART_HandleTypeDef *huart, uint32_t baud);
// ✓ 正确做法:头文件干干净净 #include <stdint.h> void hal_uart_init(uint8_t port, uint32_t baud);
|
头文件是你HAL层的"公开契约",它面向的是业务层代码。一旦头文件里出现了 UART_HandleTypeDef
或者 GPIO_TypeDef* 这些平台类型,依赖链就建立了,移植的时候上层代码必然要改。
平台相关的头文件只应该出现在 .c 实现文件里,绝不能出现在对外的 .h 里。
原则三:用枚举或ID代替硬件句柄
业务代码不应该直接持有硬件句柄(huart1、hspi2),而是通过一个逻辑ID来引用资源:
typedef enum { HAL_UART_PORT_DEBUG = 0, // 调试串口 HAL_UART_PORT_COMM, // 通信串口 HAL_UART_PORT_MAX } hal_uart_port_t;
|
业务层调用 hal_uart_send(HAL_UART_PORT_COMM, data, len),至于
HAL_UART_PORT_COMM 底层对应的是UART1还是UART3,由HAL层的平台实现决定。
这样做的好处是双重的:换芯片时业务层不用改,换硬件连线时也不用改。
原则四:初始化和运行时操作分开
HAL层的接口通常分两类:
• 配置类:init、deinit、config,在系统启动阶段调用
• 运行类:send、recv、read、write,在业务运行阶段调用
把这两类清晰地分开,有助于移植时快速定位需要适配的范围。配置类接口往往跟平台差异关系最大,运行类接口相对更稳定。
三、手把手设计一个跨平台HAL层
理论说够了,来看实际代码。我以最常用的GPIO和UART为例,完整演示一个HAL层从接口定义到多平台实现的过程。
3.1 GPIO的HAL接口设计
GPIO可能是嵌入式里最简单的外设了,但也是最容易写得到处都是硬编码的。来看一个干净的HAL接口应该长什么样:
/* hal_gpio.h */ #ifndef HAL_GPIO_H #define HAL_GPIO_H
#include <stdint.h>
typedef enum { GPIO_DIR_INPUT = 0, GPIO_DIR_OUTPUT } hal_gpio_dir_t;
typedef enum { GPIO_PULL_NONE = 0, GPIO_PULL_UP, GPIO_PULL_DOWN } hal_gpio_pull_t;
typedef enum { GPIO_LEVEL_LOW = 0, GPIO_LEVEL_HIGH } hal_gpio_level_t;
typedef struct { hal_gpio_dir_t dir; hal_gpio_pull_t pull; hal_gpio_level_t init_level; // 输出模式下的初始电平 } hal_gpio_config_t;
// 用一个整型ID标识引脚,具体映射由平台层定义 typedef uint16_t hal_gpio_pin_t;
int hal_gpio_init(hal_gpio_pin_t pin, const hal_gpio_config_t *cfg); void hal_gpio_write(hal_gpio_pin_t pin, hal_gpio_level_t level); hal_gpio_level_t hal_gpio_read(hal_gpio_pin_t pin); void hal_gpio_toggle(hal_gpio_pin_t pin);
#endif
|
注意几个要点:
• 没有包含任何平台头文件
• 引脚用一个 uint16_t 表示,不是 GPIO_TypeDef* + GPIO_Pin
• 配置用统一的结构体,不暴露寄存器概念(没有"推挽/开漏"这种跟具体硬件绑定太紧的东西,如果需要可以再加枚举,但命名要通用)
然后是引脚的定义。每个平台有自己的引脚映射文件:
/* board_pin_def.h —— STM32平台的引脚定义 */ #define PIN_LED_RUN ((0 << 8) | 5) // PA5 #define PIN_LED_ERR ((1 << 8) | 14) // PB14 #define PIN_KEY_USER ((2 << 8) | 13) // PC13 #define PIN_SENSOR_POWER ((3 << 8) | 0) // PD0
|
这里用高8位表示端口号、低8位表示引脚号,只是一种编码方式。关键是业务代码里写 hal_gpio_write(PIN_LED_RUN,
GPIO_LEVEL_HIGH),而不是 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5,
GPIO_PIN_SET)。
3.2 STM32平台的GPIO实现
/* hal_gpio_stm32.c */ #include "hal_gpio.h" #include "stm32f4xx_hal.h"
static GPIO_TypeDef* get_port(hal_gpio_pin_t pin) { GPIO_TypeDef* ports[] = {GPIOA, GPIOB, GPIOC, GPIOD, GPIOE}; uint8_t port_idx = (pin >> 8) & 0xFF; return ports[port_idx]; }
static uint16_t get_pin_mask(hal_gpio_pin_t pin) { return 1 << (pin & 0xFF); }
void hal_gpio_write(hal_gpio_pin_t pin, hal_gpio_level_t level) { GPIO_TypeDef *port = get_port(pin); uint16_t mask = get_pin_mask(pin); HAL_GPIO_WritePin(port, mask, level == GPIO_LEVEL_HIGH ? GPIO_PIN_SET : GPIO_PIN_RESET); }
hal_gpio_level_t hal_gpio_read(hal_gpio_pin_t pin) { GPIO_TypeDef *port = get_port(pin); uint16_t mask = get_pin_mask(pin); return HAL_GPIO_ReadPin(port, mask) == GPIO_PIN_SET ? GPIO_LEVEL_HIGH : GPIO_LEVEL_LOW; }
|
3.3 GD32平台的GPIO实现
/* hal_gpio_gd32.c */ #include "hal_gpio.h" #include "gd32e50x.h"
static uint32_t get_port(hal_gpio_pin_t pin) { uint32_t ports[] = {GPIOA, GPIOB, GPIOC, GPIOD, GPIOE}; uint8_t port_idx = (pin >> 8) & 0xFF; return ports[port_idx]; }
static uint32_t get_pin_mask(hal_gpio_pin_t pin) { return BIT(pin & 0xFF); }
void hal_gpio_write(hal_gpio_pin_t pin, hal_gpio_level_t level) { uint32_t port = get_port(pin); uint32_t mask = get_pin_mask(pin); if (level == GPIO_LEVEL_HIGH) gpio_bit_set(port, mask); else gpio_bit_reset(port, mask); }
hal_gpio_level_t hal_gpio_read(hal_gpio_pin_t pin) { uint32_t port = get_port(pin); uint32_t mask = get_pin_mask(pin); return gpio_input_bit_get(port, mask) == SET ? GPIO_LEVEL_HIGH : GPIO_LEVEL_LOW; }
|
两份实现文件的接口签名完全一样,内部各自调用各自的平台库。编译的时候根据目标平台选择链接哪个 .c
文件就行。
业务代码始终是这样的,不管底层是什么芯片:
/* app_led.c —— 业务层,不需要知道MCU型号 */ #include "hal_gpio.h" #include "board_pin_def.h"
void led_run_on(void) { hal_gpio_write(PIN_LED_RUN, GPIO_LEVEL_HIGH); } void led_run_off(void) { hal_gpio_write(PIN_LED_RUN, GPIO_LEVEL_LOW); }
|
3.4 UART的HAL接口设计
UART比GPIO复杂一些,因为涉及中断接收和回调通知。来看一个比较完整的接口设计:
/* hal_uart.h */ #ifndef HAL_UART_H #define HAL_UART_H
#include <stdint.h>
typedef enum { HAL_UART_PORT_0 = 0, HAL_UART_PORT_1, HAL_UART_PORT_MAX } hal_uart_port_t;
typedef struct { uint32_t baudrate; uint8_t data_bits; // 8, 9 uint8_t stop_bits; // 1, 2 uint8_t parity; // 0=none, 1=odd, 2=even } hal_uart_config_t;
typedef void (*hal_uart_rx_cb_t)(hal_uart_port_t port, const uint8_t *data, uint16_t len);
int hal_uart_init(hal_uart_port_t port, const hal_uart_config_t *cfg); int hal_uart_send(hal_uart_port_t port, const uint8_t *data, uint16_t len); void hal_uart_set_rx_callback(hal_uart_port_t port, hal_uart_rx_cb_t cb); void hal_uart_deinit(hal_uart_port_t port);
#endif
|
这套接口有几个设计上的考量:
1. 配置结构体只包含通用参数:波特率、数据位、停止位、校验,这些是所有UART都有的概念。至于DMA配置、FIFO深度、中断优先级这些平台相关的细节,放在平台实现里的内部配置中。
2. 接收用回调机制:注册一个回调函数,底层收到数据后调用它。这样做既解耦了收发逻辑,也让不同平台可以自由选择中断、DMA还是轮询的接收方式,上层不感知。
3. 返回值统一约定:成功返回0或正数,失败返回负数。不使用平台特定的错误码。
整个接口和实现的关系,可以用下面这张图总结:
三个平台的实现文件对外提供完全一样的函数签名,内部各自调用各自的驱动API。编译的时候只需要把对应平台的
.c 文件加入构建列表即可。
四、工程结构:怎么把多平台代码组织起来
接口设计好了,还有一个很实际的问题:这些文件该怎么组织?编译的时候怎么切换平台?
4.1 推荐的目录结构
project/ ├── app/ ← 应用层(平台无关) │ ├── main.c │ ├── app_sensor.c │ └── app_comm.c │ ├── service/ ← 服务层(平台无关) │ ├── protocol.c │ └── data_mgr.c │ ├── hal/ ← HAL层 │ ├── include/ ← 统一接口头文件(平台无关) │ │ ├── hal_gpio.h │ │ ├── hal_uart.h │ │ ├── hal_spi.h │ │ ├── hal_i2c.h │ │ └── hal_timer.h │ │ │ ├── stm32f4/ ← STM32F4的实现 │ │ ├── hal_gpio_stm32.c │ │ ├── hal_uart_stm32.c │ │ └── board_pin_def.h │ │ │ ├── gd32e5/ ← GD32E5的实现 │ │ ├── hal_gpio_gd32.c │ │ ├── hal_uart_gd32.c │ │ └── board_pin_def.h │ │ │ └── nrf52/ ← nRF52的实现 │ ├── hal_gpio_nrf52.c │ ├── hal_uart_nrf52.c │ └── board_pin_def.h │ └── platform/ ← 平台启动代码 ├── stm32f4/ │ ├── startup.s │ └── system_stm32f4xx.c └── gd32e5/ ├── startup.s └── system_gd32e50x.c
|
这个结构的核心思路是:
• hal/include/ 放的是统一的头文件,所有平台共用,这是契约
• hal/stm32f4/、hal/gd32e5/ 等目录放各平台的实现,这是适配
• app/ 和 service/ 完全不包含任何平台相关代码,这是目标
用一张图来展示依赖方向:
4.2 用CMake做平台切换
如果你用CMake构建(Keil用户可以跳到下一小节),平台切换可以通过一个变量搞定:
# CMakeLists.txt
set(PLATFORM "stm32f4" CACHE STRING "Target platform")
# 公共源码——所有平台都编译 set(COMMON_SOURCES app/main.c app/app_sensor.c service/protocol.c )
# HAL层——根据平台选择对应的实现文件 file(GLOB HAL_SOURCES "hal/${PLATFORM}/*.c")
# 头文件路径 include_directories( hal/include # 统一接口 hal/${PLATFORM} # 平台特有的board_pin_def.h等 platform/${PLATFORM} # 平台启动相关 )
add_executable(firmware ${COMMON_SOURCES} ${HAL_SOURCES})
|
编译的时候用 -DPLATFORM=gd32e5 就能切换平台,所有业务代码自动复用。
4.3 Keil环境下的做法
Keil不太方便用CMake,但可以用多Target来实现同样的效果:
1. 在Keil里建两个Target:STM32F4_Release 和 GD32E5_Release
2. 两个Target共享 app/、service/、hal/include/ 下的源文件
3. STM32的Target添加 hal/stm32f4/ 下的 .c 文件
4. GD32的Target添加 hal/gd32e5/ 下的 .c 文件
5. 各自设置好对应的Include Path和启动文件
切换Target就是切换平台,一个工程同时维护多个平台的构建配置。
4.4 条件编译:最后的手段,不是第一选择
有人习惯用 #ifdef 在同一个文件里写多平台代码:
void hal_uart_send(uint8_t port, const uint8_t *data, uint16_t len) { #if defined(PLATFORM_STM32) HAL_UART_Transmit(&huart1, data, len, 100); #elif defined(PLATFORM_GD32) for (int i = 0; i < len; i++) { usart_data_transmit(USART0, data[i]); while (RESET == usart_flag_get(USART0, USART_FLAG_TBE)); } #elif defined(PLATFORM_NRF52) nrfx_uarte_tx(&uart_inst, data, len); #endif }
|
平台少的时候还能接受。但当平台超过三个,或者每个外设的实现差异很大时,这种写法会变成噩梦,一个文件里到处都是
#ifdef,可读性急剧下降,改一个平台的代码时很容易误伤另一个平台。
我的建议是:条件编译只用在少量、简单的差异上(比如一两行的宏定义差异),大块的实现差异一律用独立文件分离。
五、进阶:用函数指针实现驱动的"可拔插"
前面介绍的方案是通过编译期选择不同的 .c 文件来切换平台。对于大多数嵌入式项目,这已经足够了。
但有些场景需要更高的灵活性,比如一个产品同时支持两种通信模块(蓝牙和WiFi),运行时根据配置或外部条件选择使用哪一个。又比如你在做一套通用固件框架,希望驱动可以像"插件"一样注册进来。
这时候,可以把HAL层的接口设计成函数指针结构体 + 注册机制:
/* hal_comm.h —— 通用通信接口 */
typedef struct { int (*init)(void *config); int (*send)(const uint8_t *data, uint16_t len); int (*recv)(uint8_t *buf, uint16_t max_len, uint32_t timeout_ms); void (*deinit)(void); } hal_comm_ops_t;
typedef struct { const char *name; const hal_comm_ops_t *ops; } hal_comm_driver_t;
int hal_comm_register(const hal_comm_driver_t *drv); int hal_comm_send(const uint8_t *data, uint16_t len); int hal_comm_recv(uint8_t *buf, uint16_t max_len, uint32_t timeout_ms);
|
蓝牙模块和WiFi模块各自实现一套 ops:
/* drv_ble.c */ static int ble_init(void *cfg) { /* BLE初始化 */ return 0; } static int ble_send(const uint8_t *d, uint16_t l) { /* BLE发送 */ return l; } static int ble_recv(uint8_t *b, uint16_t m, uint32_t t) { /* BLE接收 */ return 0; } static void ble_deinit(void) { /* BLE关闭 */ }
static const hal_comm_ops_t ble_ops = { .init = ble_init, .send = ble_send, .recv = ble_recv, .deinit = ble_deinit, };
const hal_comm_driver_t g_ble_driver = { .name = "BLE", .ops = &ble_ops, };
|
/* drv_wifi.c */ static const hal_comm_ops_t wifi_ops = { .init = wifi_init, .send = wifi_send, .recv = wifi_recv, .deinit = wifi_deinit, };
const hal_comm_driver_t g_wifi_driver = { .name = "WiFi", .ops = &wifi_ops, };
|
系统启动时根据实际情况注册:
void system_init(void) { if (board_has_wifi_module()) hal_comm_register(&g_wifi_driver); else hal_comm_register(&g_ble_driver); }
|
业务层调用 hal_comm_send 的时候,完全不知道底层走的是蓝牙还是WiFi。
这种模式在Linux内核、RT-Thread、Zephyr等系统中被广泛使用,本质上就是C语言版的"多态"。它的威力在于:
不过,这种设计也有代价:函数指针调用比直接调用多一次间接寻址,对于极端性能敏感的场景(比如高速ADC采样的中断服务函数)需要权衡。在大多数业务场景下,这点开销完全可以忽略。
六、实战中容易踩的几个坑
HAL层看起来思路清晰,但实际做的时候有不少细节容易出问题。我把几个高频踩坑点列出来,帮你少走弯路。
6.1 中断处理的跨平台适配
中断是HAL层设计里最棘手的部分。不同平台的中断机制差异很大:STM32用全局回调函数(如 HAL_UART_RxCpltCallback),nRF52用事件处理函数,有些平台用中断向量直接注册。
推荐的做法是:HAL层内部处理中断,对上层只暴露回调注册接口。
关键原则:中断服务函数里只做最少的事情(读数据、清标志、通知上层),复杂逻辑放到回调函数或任务中处理。
6.2 时钟和延时的抽象
很多人会在业务代码里直接调用 HAL_Delay() 或者 nrf_delay_ms(),这也是平台依赖的一种。建议封装一个统一的延时接口:
/* hal_tick.h */ void hal_delay_ms(uint32_t ms); uint32_t hal_get_tick_ms(void);
|
实现可以基于SysTick、定时器、或RTOS的 osDelay,但上层不需要关心。
6.3 不要过度抽象
这是另一个极端。有些工程师看了分层设计之后,恨不得把所有东西都抽象一遍,连一个简单的延时都要搞三层封装。
过度抽象的典型症状:
• 一个函数调用链经过5层才到达真正干活的代码
• 为了支持"将来可能的需求"预留了大量从未被使用的接口
• 接口设计得过于通用,导致每个平台的实现都得写一堆适配代码
嵌入式系统资源有限,过多的抽象层会带来代码膨胀和性能损耗。HAL层通常一层就够了,不需要在HAL之上再套一层"通用设备层"再套一层"服务层接口"。
判断标准很简单:如果你当前只有一两个平台要适配,就按一两个平台的实际差异去设计。 等真正有第三个平台的时候,再根据实际情况调整抽象粒度。
6.4 外设初始化的顺序依赖
MCU的外设初始化往往有顺序要求:先开时钟、再配GPIO、然后初始化外设。这些顺序依赖在不同平台上可能不同。
建议在每个平台目录下放一个 board_init.c,把所有初始化的顺序逻辑集中管理:
/* hal/stm32f4/board_init.c */ void board_init(void) { HAL_Init(); SystemClock_Config(); // 按依赖顺序初始化各外设 hal_gpio_init_all(); hal_uart_init(HAL_UART_PORT_0, &debug_uart_cfg); hal_spi_init(HAL_SPI_PORT_0, &flash_spi_cfg); hal_timer_init(); }
|
上层只调用一个 board_init(),不感知具体的初始化顺序和依赖关系。
七、看看成熟项目是怎么做的
自己设计HAL层之前,花点时间研究几个成熟的开源项目,能少走很多弯路。这里推荐三个值得深入学习的参考:
RT-Thread 的设备驱动框架
RT-Thread用了一套非常经典的设计:所有设备都注册为 rt_device_t 对象,通过统一的
rt_device_read / write / control 接口访问。底层驱动只需要实现一组操作函数并注册进框架即可。
它的HAL层设计核心是设备对象 + 操作函数表,和我们前面讲的函数指针结构体思路是一脉相承的,只是更加系统化。如果你的项目有使用RTOS的计划,RT-Thread的驱动框架值得仔细研读。
Zephyr RTOS 的设备树模型
Zephyr走了更工程化的路线:用设备树(Device Tree)描述硬件配置,编译时自动生成绑定代码。驱动通过
DEVICE_DT_INST_DEFINE 宏注册,应用通过 device_get_binding
获取设备句柄。
这种方式的好处是换硬件只需要改设备树文件(.dts),C代码一行不动。缺点是学习曲线比较陡,对于简单项目有点重。不过其设计思想,把硬件描述和驱动实现分离,非常值得借鉴。
Arduino 的 HAL 抽象
别看Arduino表面上是"入门玩具",它的底层抽象做得相当好。digitalWrite、analogRead、Serial.begin
这些API,在AVR、ESP32、STM32、RP2040上写法完全一样,底层实现由各平台的core包提供。
Arduino的设计哲学是极致的简洁,接口尽可能少、参数尽可能简单。这一点对HAL层设计有很好的启发:不是参数越多越灵活,而是接口越简单越容易移植。
三种方案的对比:
你不需要照搬任何一个方案。但理解它们的设计取舍,会帮助你在自己的项目中做出更合理的决策。
八、总结:HAL层设计的核心检查清单
最后做一个总结。如果你正在设计或重构自己项目的HAL层,可以对照这份清单逐项检查:
接口设计:
• 头文件里是否完全不依赖任何平台特定的类型和头文件?
• 外设引用是否通过枚举或ID,而不是直接传递硬件句柄?
• 配置参数是否只包含通用概念(波特率、数据位),不包含平台细节(DMA通道、中断优先级)?
• 返回值是否有统一的错误码约定?
工程组织:
• 统一接口头文件和平台实现文件是否物理分离(不同目录)?
• 切换平台是否只需要更换实现文件,不需要修改头文件和业务代码?
• 是否有一个集中的板级配置文件(引脚定义、外设映射)?
• 条件编译 #ifdef 是否控制在最小范围内?
运行机制:
• 中断处理是否被HAL层内部消化,对上层只暴露回调接口?
• 初始化顺序和依赖关系是否集中管理?
• 延时和系统时钟是否有统一的抽象接口?
做到以上这些,你的代码在面对平台切换时,需要改动的范围就被严格限制在了HAL层的实现文件和板级配置文件中。业务代码、协议处理、状态机逻辑,这些真正值钱的代码,一行都不用动。
HAL层设计其实是嵌入式软件架构中"分层思想"的一个具体应用。它解决的是"代码和硬件怎么解耦"的问题。但真实项目中的架构挑战远不止于此,模块之间的依赖怎么管理、状态逻辑怎么组织、事件通知怎么解耦、RTOS任务怎么划分、接口怎么设计才不会随着需求膨胀而崩溃……这些问题环环相扣,只解决其中一个是不够的。
如果你在项目中遇到过"代码越写越乱、改一个功能牵一堆模块、换个需求像重写一遍"这类困扰,那大概率不只是HAL层的问题,而是整体的软件架构设计需要系统性地梳理。 |