做嵌入式开发的,大概都经历过这种场景:
项目做到一半,供应链那边传来消息——"STM32缺货,换GD32吧"。于是你打开工程,看着满屏的 HAL_UART_Transmit()、HAL_GPIO_WritePin(),心里一阵发凉。虽说GD32号称"兼容STM32",但库函数的细节差异、寄存器的微妙不同,还是得一个个文件去改、去调、去测。
更惨的是,如果公司同时有好几个产品线,用着不同厂家的芯片,那代码基本就是"一个平台一套"。同样的业务逻辑,在A项目写一遍,在B项目复制粘贴改一遍,在C项目再来一遍。每次改需求,都得改好几份代码,漏改一处就是一个坑。
有没有办法让同一套业务代码,能在不同芯片平台上跑起来,换平台的时候只改底层驱动,上层逻辑一行不动?
答案是有的,关键在于——模块设计时做好分层和抽象。
今天这篇文章,就以一个UART通信模块为例,手把手演示如何设计一个能跨平台复用的嵌入式模块。不整虚的,直接上代码。
跨平台设计的本质就一句话:把"变的部分"和"不变的部分"分开。
什么是"不变的部分"?业务逻辑。不管你用STM32还是GD32,"收到指令A就执行动作B"这个逻辑是不变的。
什么是"变的部分"?硬件操作。同样是发一个字节,STM32调 HAL_UART_Transmit(),GD32可能调 usart_data_transmit(),底层API不一样。
所以设计思路就是:在业务逻辑和硬件操作之间,插入一个抽象层。

这张图的意思是:
uart_send(),它不关心底层是STM32还是GD32.h 头文件)换平台的时候怎么办?只需要切换编译哪个平台的 .c 文件,应用层代码一行不用动。
这就是所谓的**"依赖倒置"**——上层不依赖下层的具体实现,而是依赖抽象接口。
理论说完了,怎么落地?先从目录结构开始规划。
一个支持跨平台的模块,推荐这样组织代码:
project/├── app/ # 应用层代码│ ├── main.c│ └── protocol.c # 协议解析等业务逻辑│├── modules/ # 可复用模块│ └── uart/│ ├── uart.h # 抽象层接口定义(平台无关)│ ├── uart.c # 抽象层通用逻辑(如果有的话)│ └── port/ # 平台适配层│ ├── uart_stm32.c│ ├── uart_gd32.c│ └── uart_esp32.c│├── platform/ # 平台相关配置│ ├── stm32/│ │ └── platform_config.h│ └── gd32/│ └── platform_config.h│└── Makefile / CMakeLists.txt这个结构的好处是:
uart.h 放在模块目录下,定义统一接口,所有平台都 #include 这个头文件port/ 目录下按平台分文件,编译时只选择其中一个uart.h,完全不知道底层是哪个平台切换平台时,只需要在 Makefile 或 IDE 里改一下编译哪个 uart_xxx.c,其他代码不用动。
光说不练假把式。下面用一个完整的UART模块来演示整个流程。
先写 uart.h,定义统一的接口规范。这个文件是所有平台共用的,也是应用层唯一需要包含的头文件。
// uart.h —— 抽象层接口定义#ifndef __UART_H__#define __UART_H__#include <stdint.h>// 错误码定义typedefenum { UART_OK = 0, UART_ERR_PARAM = -1, UART_ERR_BUSY = -2, UART_ERR_TIMEOUT = -3,} uart_err_t;// UART配置结构体typedefstruct { uint32_t baudrate; // 波特率 uint8_t data_bits; // 数据位: 8, 9 uint8_t stop_bits; // 停止位: 1, 2 uint8_t parity; // 校验: 0-无, 1-奇, 2-偶} uart_config_t;// 接收回调函数类型typedef void (*uart_rx_callback_t)(uint8_t *data, uint16_t len);/*====== 以下是平台需要实现的接口 ======*/// 初始化uart_err_t uart_init(const uart_config_t *config);// 发送数据uart_err_t uart_send(const uint8_t *data, uint16_t len);// 注册接收回调uart_err_t uart_set_rx_callback(uart_rx_callback_t callback);// 反初始化void uart_deinit(void);#endif // __UART_H__设计要点:
uart_err_t 统一返回值,别用裸的 int针对STM32,用HAL库实现上面的接口:
// uart_stm32.c —— STM32平台实现#include "uart.h"#include "stm32f1xx_hal.h"static UART_HandleTypeDef huart1;static uart_rx_callback_t rx_callback = NULL;static uint8_t rx_byte;uart_err_t uart_init(const uart_config_t *config){ if (config == NULL) return UART_ERR_PARAM; huart1.Instance = USART1; huart1.Init.BaudRate = config->baudrate; huart1.Init.WordLength = (config->data_bits == 9) ? UART_WORDLENGTH_9B : UART_WORDLENGTH_8B; huart1.Init.StopBits = (config->stop_bits == 2) ? UART_STOPBITS_2 : UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; if (HAL_UART_Init(&huart1) != HAL_OK) { return UART_ERR_PARAM; } // 开启接收中断 HAL_UART_Receive_IT(&huart1, &rx_byte, 1); return UART_OK;}uart_err_t uart_send(const uint8_t *data, uint16_t len){ if (data == NULL || len == 0) return UART_ERR_PARAM; HAL_StatusTypeDef ret = HAL_UART_Transmit(&huart1, (uint8_t *)data, len, 1000); return (ret == HAL_OK) ? UART_OK : UART_ERR_TIMEOUT;}uart_err_t uart_set_rx_callback(uart_rx_callback_t callback){ rx_callback = callback; return UART_OK;}void uart_deinit(void){ HAL_UART_DeInit(&huart1);}// HAL库接收完成回调void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart){ if (huart->Instance == USART1) { if (rx_callback) { rx_callback(&rx_byte, 1); } HAL_UART_Receive_IT(&huart1, &rx_byte, 1); // 继续接收 }}换到GD32平台,用GD32的标准库实现同样的接口:
// uart_gd32.c —— GD32平台实现#include "uart.h"#include "gd32f10x.h"static uart_rx_callback_t rx_callback = NULL;uart_err_t uart_init(const uart_config_t *config){ if (config == NULL) return UART_ERR_PARAM; // 使能时钟 rcu_periph_clock_enable(RCU_USART0); rcu_periph_clock_enable(RCU_GPIOA); // 配置GPIO gpio_init(GPIOA, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_9); // TX gpio_init(GPIOA, GPIO_MODE_IN_FLOATING, GPIO_OSPEED_50MHZ, GPIO_PIN_10); // RX // 配置USART usart_deinit(USART0); usart_baudrate_set(USART0, config->baudrate); usart_word_length_set(USART0, USART_WL_8BIT); usart_stop_bit_set(USART0, USART_STB_1BIT); usart_parity_config(USART0, USART_PM_NONE); usart_transmit_config(USART0, USART_TRANSMIT_ENABLE); usart_receive_config(USART0, USART_RECEIVE_ENABLE); // 使能接收中断 usart_interrupt_enable(USART0, USART_INT_RBNE); nvic_irq_enable(USART0_IRQn, 0, 0); usart_enable(USART0); return UART_OK;}uart_err_t uart_send(const uint8_t *data, uint16_t len){ if (data == NULL || len == 0) return UART_ERR_PARAM; for (uint16_t i = 0; i < len; i++) { usart_data_transmit(USART0, data[i]); while (RESET == usart_flag_get(USART0, USART_FLAG_TBE)); } return UART_OK;}uart_err_t uart_set_rx_callback(uart_rx_callback_t callback){ rx_callback = callback; return UART_OK;}void uart_deinit(void){ usart_deinit(USART0);}// GD32中断服务函数void USART0_IRQHandler(void){ if (usart_interrupt_flag_get(USART0, USART_INT_FLAG_RBNE) != RESET) { uint8_t data = usart_data_receive(USART0); if (rx_callback) { rx_callback(&data, 1); } }}应用层代码只需要包含 uart.h,完全不用关心底层是哪个平台:
// main.c —— 应用层代码(平台无关)#include "uart.h"// 接收回调处理void on_uart_received(uint8_t *data, uint16_t len){ // 收到数据后的处理逻辑 // 比如:解析协议、存入缓冲区等}int main(void){ // 配置UART参数 uart_config_t config = { .baudrate = 115200, .data_bits = 8, .stop_bits = 1, .parity = 0, }; // 初始化 if (uart_init(&config) != UART_OK) { // 初始化失败处理 while (1); } // 注册接收回调 uart_set_rx_callback(on_uart_received); // 发送数据 const char *msg = "Hello World\r\n"; uart_send((uint8_t *)msg, strlen(msg)); while (1) { // 主循环 }}注意看,main.c 里没有任何 HAL_xxx 或 usart_xxx 的调用,它只认识 uart_init、uart_send 这些抽象接口。换平台的时候,这个文件一个字符都不用改。
下面这张图展示了调用关系:

除了分文件实现,有时候也需要用条件编译来处理一些平台差异。这里分享几个实用技巧。
在项目根目录定义一个 platform.h,统一管理平台宏:
// platform.h#ifndef __PLATFORM_H__#define __PLATFORM_H__// 在编译选项或这里定义当前平台// #define PLATFORM_STM32// #define PLATFORM_GD32// #define PLATFORM_ESP32// 平台检查#if !defined(PLATFORM_STM32) && !defined(PLATFORM_GD32) && !defined(PLATFORM_ESP32) #error "Please define a platform: PLATFORM_STM32, PLATFORM_GD32, or PLATFORM_ESP32"#endif// 包含对应平台的头文件#if defined(PLATFORM_STM32) #include "stm32f1xx_hal.h"#elif defined(PLATFORM_GD32) #include "gd32f10x.h"#elif defined(PLATFORM_ESP32) #include "esp_system.h"#endif#endif这样做的好处是:忘了定义平台宏,编译时直接报错,不会编出一个莫名其妙的固件。
有些简单操作,比如关中断、临界区保护,不值得单独写一个 .c 文件,可以用宏来处理:
// platform.h 中添加#if defined(PLATFORM_STM32) #define ENTER_CRITICAL() __disable_irq() #define EXIT_CRITICAL() __enable_irq() #define DELAY_MS(ms) HAL_Delay(ms)#elif defined(PLATFORM_GD32) #define ENTER_CRITICAL() __disable_irq() #define EXIT_CRITICAL() __enable_irq() #define DELAY_MS(ms) delay_1ms(ms)#elif defined(PLATFORM_ESP32) #define ENTER_CRITICAL() portDISABLE_INTERRUPTS() #define EXIT_CRITICAL() portENABLE_INTERRUPTS() #define DELAY_MS(ms) vTaskDelay(pdMS_TO_TICKS(ms))#endif应用层代码直接用 DELAY_MS(100),不用管底层是 HAL_Delay 还是 vTaskDelay。
推荐把平台选择放在构建系统里,而不是手动改代码:
# Makefile 示例PLATFORM ?= STM32 # 默认STM32,可通过 make PLATFORM=GD32 切换ifeq ($(PLATFORM), STM32) CFLAGS += -DPLATFORM_STM32 SRC += uart_stm32.celse ifeq ($(PLATFORM), GD32) CFLAGS += -DPLATFORM_GD32 SRC += uart_gd32.cendif切换平台只需要一行命令:
make PLATFORM=GD32 # 编译GD32版本make PLATFORM=STM32 # 编译STM32版本条件编译用得太多,代码会变成这样:
// 反面教材 —— 条件编译地狱void some_function(void){ #if defined(PLATFORM_STM32) HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET); #elif defined(PLATFORM_GD32) gpio_bit_set(GPIOA, GPIO_PIN_5); #elif defined(PLATFORM_ESP32) gpio_set_level(GPIO_NUM_5, 1); #endif // ... 一堆 #if #elif #endif ...}这种代码读起来很痛苦。正确做法还是回到抽象层——把 gpio_write() 抽象出来,条件编译只出现在底层实现文件里,上层代码保持干净。
原则:条件编译应该集中在少数几个文件里(平台实现文件、platform.h),不要散落在业务代码中。
回顾一下,嵌入式模块跨平台设计的核心就三件事:
分层、抽象、隔离变化
具体落地的步骤:
xxx.h | ||
xxx_stm32.cxxx_gd32.c 等 | ||
modules/xxx/port/ | ||
这套方法不仅适用于UART,GPIO、SPI、I2C、定时器、Flash操作……几乎所有跟硬件打交道的模块都可以这样设计。
最后画一张完整的架构图,帮你建立整体印象:

几点实践建议:
跨平台设计不是什么高深技术,本质就是把"变"和"不变"分开。前期多花一点时间设计接口,后期换平台、加平台的时候,能省下的时间是十倍百倍的。
这笔账,值得算。
如果觉得文章有帮助,欢迎点赞、在看、转发,你的支持是我持续输出的动力。