2026/10/23 8:23:31

嵌入式去平台化实战:用HAL层与多平台构建提升固件可移植性

嵌入式去平台化实战:用HAL层与多平台构建提升固件可移植性 做嵌入式开发这些年我越来越感觉到一件事“跟某个具体平台绑定得越深后面翻车的概率就越大。”这里说的“去平台化”不是让你把所有代码都写成空中楼阁也不是搞一堆花里胡哨的抽象层来显得架构高级。它说的是一个非常实际的问题你写的这套固件到底是被一家芯片、一个操作系统、一套工具链绑死了还是一份代码可以相对轻松地挪到另一个硬件平台上去跑这个话题最近在嵌入式社区里讨论得很多原因也很现实。芯片动不动就缺货涨价、型号说停产就停产、项目做到一半客户要求换方案、同一个产品线要同时出高配低配好几个硬件版本……这些事但凡你经历过一次就会明白“可移植性”不是加分项而是保命项。这篇文章我不会跟你聊虚的。我会从方案选型时的顶层思考到实际写代码时如何搭HAL层、如何隔离操作系统接口再到多平台构建和测试的具体落地方法全程用我做过的某款数据采集终端的真实改造经历来拆解。如果你是做单片机、RTOS、Linux嵌入式方向或者正在纠结要不要在项目里搞“去平台化”这篇文章应该能帮你省不少时间。1. 先聊聊“去平台化”到底在说什么1.1 一个真实到有点疼的场景有一回我手上的一个项目已经做到POC阶段了功能验证都通过正准备排产试制。结果采购那边跑过来说主控芯片交期拉到了四十多周建议换料。我当时第一反应是换就换吧反正都是Cortex-M系列接口重新改一改就好。结果真动手的时候才发现事情远没那么简单时钟树配置不一样、GPIO基地址不一样、串口DMA的中断处理逻辑不一样、Flash驱动不一样、甚至编译器的字节对齐行为都略有差别。光是把底层驱动理顺、把startup文件对应上、把打印日志来源改对就花了整整三天。那三天里我脑子里反复就一句话当时写代码的时候如果能在“寄存器操作”和“业务逻辑”之间多隔一道墙现在至于这么痛苦吗这就是“平台绑定”的代价平时看不到到了需要换平台的时候它就是一笔沉重的技术债。1.2 被绑定的不只是芯片很多人以为“去平台化”就是换芯片时少改点代码其实这只是最表层的一层。真正束缚手脚的通常是下面这三样硬件平台绑定用到了哪颗芯片、哪些外设寄存器、中断控制器怎么配、DMA通道怎么选全都跟具体硅片强相关。操作系统绑定你要是用了某个RTOS的私有API来写线程和消息队列那换一个RTOS这些代码基本就得重写。工具链与交付形式绑定代码只能在某个IDE里编译、只能在某个调试器下跑、只能配某家编译器一旦团队换工具或者外部环境变化整个工程就陷入半瘫痪状态。所以我在面试或带人时经常说一句话“什么叫平台无关就是把这三层绑定的绳子一层一层剪开。”剪开之后你的业务代码只依赖你自己的抽象层而这个抽象层可以各自适配不同的底层实现。这种分层思路本质上是把“最容易变的东西”和“最不容易变的东西”隔离开。2. 为什么现在大家都在谈去平台化2.1 芯片选型的确定性变差了前几年大家选MCU基本只看性能和价格剩下的事情都是一条路走到黑。但最近几年芯片采购这件事的不确定性已经高到不亚于硬件设计本身。我就见过不止一个团队因为主力型号停产或者交期拉长不得不把整个硬件方案推翻重来。有的项目被逼着从某颗传统商业内核切换到开放指令集架构比如RISC-V的产品上有的项目则是在同一个内核生态里从A家换到B家。你会发现不管是哪种情况软件层的可移植性直接决定了切换成本的上限。如果你的代码早就按“平台无关”的方式组织好了换芯片就是换一个BSP的事如果你当年图省事在业务代码里直接写寄存器地址那基本就是推倒重来。2.2 软件栈的复杂度上来了十年前做单片机一个裸机循环加几个中断就搞定了根本不需要考虑什么分层。现在呢带屏的HMI、网络协议栈、OTA升级、文件系统、加密、音视频解码哪个不是动辄几万行代码的模块一旦软件栈变复杂你的代码就不仅仅是给这台设备用的它要被多个产品、多代硬件长期维护。在这个背景下如果你是直接调底层库和寄存器来做事任何一个底层变化都会引发连锁爆炸。反观那些成熟的开源RTOS和Linux系方案它们普遍走的是“标准化接口 多平台适配”的路线。你不用管底层是什么芯片只要按标准API写逻辑下面有人帮你适配好。这个思路正是嵌入式“去平台化”最重要的底气。2.3 团队与产品线的现实需求除了供应链压力和技术复杂度团队自身也在变。一个人写固件写到退休的现象越来越少了代码交到别人手里人家不可能跟着你去学那一堆只有你懂的私有宏和寄存器定义。可移植性强、分层清晰的代码本身就是一种“团队资产”它让小团队可以并行开发也让新人能更快上手。如果你的公司同时做五六个产品每个产品用的主控还不一样那就更明显了底层驱动各写各的应用层逻辑却高度相似。与其每个项目组都从头维护一套串口、Flash、网络、日志代码不如把应用逻辑抽取成平台无关的模块底部只留一个很薄的适配层。这样无论是起新项目、换主控还是把功能模块复用到另一个产品线都轻松很多。3. 落到代码上去平台化该怎么设计3.1 硬件访问层别让业务代码碰寄存器真正可落地的“去平台化”第一步永远是从代码层面把“寄存器操作”隔离出来。我在这里说的不是让你写一个巨复杂的面向对象驱动框架而是建议你至少做到这么几点所有外设操作比如串口收发、SPI读写、GPIO控制都封装成函数而不是在项目里到处直接写*(volatile uint32_t *)0x40021000这种裸指针。把跟芯片型号强相关的配置比如时钟初始化、引脚复用、中断优先级统一放到一个叫board_init或者platform_init的文件里让应用代码只在启动时调用一次。对外设的操作参数不要直接暴露寄存器名称、位域名称而是用自己的枚举和结构体。比如你要做一个串口发送业务代码里应该长这样const char msg[] hello platform\r\n; uart_send(UART_PORT_A, (const uint8_t *)msg, sizeof(msg) - 1);至于uart_send内部用的是哪颗芯片的哪个外设、DMA怎么配、中断怎么开那都是平台文件里的事业务层完全不用关心。这个抽象习惯看着简单但实际很多项目里做得很差。最常见的情况是某个工程师为了图方便在业务代码里用寄存器位定义直接操作GPIO翻转结果这个文件到了换芯片的时候每行都变成了一颗雷。3.2 OS接口层站在标准的肩膀上接下来是操作系统绑定的问题。如果你用的是裸机调度那还好说只需要自己约定好一个任务调度接口。但如果用了RTOS尤其是不止一个RTOS那对线程、信号量、消息队列的调用就一定要做隔离。最省事的办法是直接让业务代码基于POSIX风格的接口来写。虽然POSIX在MCU场景下不会100%覆盖但类似pthread风格的任务创建、sem_t风格的信号量、mq_风格的消息队列在现有开源RTOS里大多都能找到兼容适配。即使不能完全兼容你自己写一个二十行的包装也很容易把“换RTOS”变成“换底层适配文件”。这个事我踩过一次坑。早期做项目时我用某款RTOS的私有API写消息队列用得非常顺手。后来因为项目要跑一个需要更大内存堆的第三方组件被迫换了一个动态内存分配策略不同的RTOS结果整个通信模块全得改。如果当初我多写一层薄薄的“os_wrap”那次的迁移就是改配置文件而不是改业务代码。注意做这层抽象时千万不要把RTOS的“能力”阉割掉。等你封装完至少要保证任务优先级、超时等待、中断中发信号这些基础动作都还能用否则就变成了“为了抽象而抽象”的形式主义。3.3 构建系统与交付让“换平台”变成“换参数”很多人写代码很规范但一到了构建工程就立刻回到石器时代某个型号的代码在工程文件里写死了换平台就新建一个工程然后复制粘贴一堆文件最后维护十个长得七七八八的项目副本。真正可行的方法是用构建系统来管理多个目标。比如用CMake你可以按“平台”这个维度来做变量开关set(TARGET_PLATFORM mcu_a CACHE STRING Select target platform) if(TARGET_PLATFORM STREQUAL mcu_a) add_subdirectory(board/mcu_a) list(APPEND PLATFORM_SOURCES board/mcu_a/startup.S board/mcu_a/board_init.c) elseif(TARGET_PLATFORM STREQUAL mcu_b) add_subdirectory(board/mcu_b) list(APPEND PLATFORM_SOURCES board/mcu_b/startup.S board/mcu_b/board_init.c) endif()这样你在同一份代码仓库里通过一个CMake参数就能编出对应平台的可执行文件。更重要的是编译目标从“一平台一工程”变成了“一仓库多目标”不同平台之间共用同一套应用源文件就不会出现“A平台的bug修了B平台忘了同步”这种低级事故。如果条件允许我甚至建议你把固件产物也做成平台参数化的形式比如在配置文件里区分PLATFORM_ID、FLASH_BASE、RAM_BASE让烧录脚本也跟着走这样一旦后面要支持新的板子纯粹就是“新增一个平台描述”的事。4. 实操记录某数据采集终端的跨平台改造这一章我拿自己做过的一个项目来拆解。项目不复杂就是一台负责采集传感器数据、通过Wi-Fi上报、支持本地日志存储和OTA升级的终端设备。最初它运行在某颗常见商业内核的单片机上用的是裸机定时器调度后来因为成本与交期原因需要移植到另一颗内核和配套的RTOS上。4.1 第一步盘点手头的“脏代码”改造最怕一上来就动手我先梳理了代码库里跟硬件和平台相关的部分。当时列出的问题大致如下表现影响业务代码里直接调用Flash寄存器写函数换Flash驱动时需要改所有调用点日志模块通过某个私有UART的寄存器地址输出换了串口号就得重新改宏定时器全部依赖某内核的SysTick写法新平台中断模型完全不一样中断服务函数里混着业务状态机逻辑复用性极差调试还麻烦编译脚本里写死了芯片型号和连接脚本换芯片后无法直接生成目标文件这张表列出来之后我自己都承认过去几轮“快速迭代”攒出来的技术债确实到了必须还的时候。4.2 第二步把抽象层分出来我没有直接把所有代码推倒重写而是先做了一个名为hal的轻量抽象层把最常用到的四类操作给“接住了”系统时钟与延时sys_time_now()、sys_delay_ms()。串口收发uart_init()、uart_write()、uart_read()。GPIO控制gpio_set()、gpio_get()、gpio_toggle()。定时器/软件定时sw_timer_create()、sw_timer_start()。原有的寄存器级代码全部下沉到hal内部对上层只暴露这几个稳定函数。原先的业务代码比如状态机、协议解析、数据缓存、Wi-Fi入网改起来其实非常机械——就是把过去直接面对寄存器的地方替换成对hal函数的调用。划重点抽象层的接口不要设计得太“大”能少则少。你每增加一个接口就意味着所有平台都要适配一个实现接口膨胀会让这个抽象层变成新的瓶颈。4.3 第三步搞定多目标构建代码改造到能同时编译两个版本之后我立刻把构建切到CMake。整个工程的目录结构大致是这样app/ ├── main.c ├── sensor.c ├── network.c ├── ota.c └── log.c hal/ ├── hal_uart.c ├── hal_gpio.c ├── hal_timer.c └── hal_sys.c board/ ├── mcu_a/ │ ├── board_init.c │ ├── startup.S │ └── link.ld └── mcu_b/ ├── board_init.c ├── startup.S └── link.ld build/ ├── mcu_a/ └── mcu_b/通过cmake -DTARGET_PLATFORMmcu_a ..和cmake -DTARGET_PLATFORMmcu_b ..我可以得到两个完全独立的固件。更贴心的是由于编译流程统一了CI服务器上可以同时跑两个平台的所有测试任务而不是为每个平台单独搭一套构建流程。4.4 第四步没有开发板也能测去平台化带来的一个额外好处是测试方式也能跟着“去平台化”。以前的裸机代码想跑单元测试必须把开发板连到电脑上而现在业务逻辑不再直接碰硬件你就可以在宿主机上直接编译一遍业务代码来做验证。比如我当时的协议解析模块和状态机模块拉到PC上加上一个模拟的发包程序就能跑完绝大部分逻辑路径测试根本不需要等到硬件就绪。等到硬件到手我再把同一份业务代码交叉编译到目标平台通过板级冒烟用例验证一下底层的hal实现是否工作正常。整个流程下来移植最费劲的已经不是“搬代码”而是“验证底层驱动写得对不对”。5. 常见问题与避坑备忘5.1 抽象层太厚性能撑不住去平台化最大的批评声音之一就是“每层抽象都带来性能损耗”。我承认这一点存在尤其是如果你把每个函数都搞成“回调注册 动态绑定”那开销确实可观。但在MCU场景下大多数接口封装在编译时就能被编译器内联优化掉只要你不故意用函数指针绕来绕去性能损失基本在可忽略的范围。我自己的做法是抽象层允许存在但禁止在中断热路径上做过度包装。比如串口中断里需要快速处理一个字节的场景可以直接用平台相关代码去写中断服务函数只要这个函数内部不去调用平台无关模块就行。把抽象用在“模块与模块之间”而不是用在“中断到任务之间”。5.2 编译器之间的“隐形差异”即使你对芯片和OS做了隔离不同编译器也会带来坑。比如某个商业编译器默认char是有符号的而GCC在某些架构上char默认也是signed但你换个平台换成RISC-V的工具链可能默认就成了unsigned。这看起来是小事在代码里一旦出现char c 0xFF后跟if (c 0xFF)这种写法直接就翻车。另外字节对齐、位域的内存布局、结构体填充规则也因编译器而异。所以我在工程里至少会做两件事在项目配置文件中明确指定-funsigned-char这类编译选项确保不同工具链行为一致。对跨平台的数据结构不用位域来定义协议字段而是用固定宽度的数组加位移操作。5.3 测试环境的搭建思路在宿主机上测试嵌入式逻辑很多人不知道从哪里下手。我的经验是不要把Windows下的IDE当成唯一的开发环境也不要在没有版本管理和CI的“裸奔”状态下搞重构。最简单的测试框架可以是用C语言写一个模拟的HAL实现把串口收发映射到本地文件或标准输入输出把时间函数映射到宿主机系统时间然后在CI上用普通的测试断言跑一遍核心业务模块的用例。等全绿了再交叉编译到目标板跑板级冒烟。注意宿主机测试主要用于验证“逻辑正确性”它不能证明“驱动在目标芯片上真的能跑”也无法完整复现中断时序。所以板上测试还是不能省两者是互补关系不存在谁替代谁。最后再分享一点我自己的体会做了几个项目的跨平台改造之后我最大的感受是“去平台化”不是一项技术而是一个成本控制策略。它不会让你的第一个版本写得更快甚至在短期内会让你多花时间设计接口、维护多个平台的适配层。但它会在产品面临换芯片、扩产线、换系统、换协作方式这些关键时刻帮你省下以“周”甚至“月”为单位的时间。如果你现在正在启动一个新产品我的建议是不用一上来就搞一大套抽象框架但你至少要画清楚“会变的”和“不变的”边界在哪里把寄存器操作、OS调用、编译器特性这些最容易变的东西用一层薄薄的代码隔离起来。这层纸未来会让你感谢自己。