ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

嵌入式开发告别古法编程:从寄存器操作到工程化实践

嵌入式开发告别古法编程:从寄存器操作到工程化实践 去年春节前我接手了一个需要紧急交付的物联网终端项目拿到的工程文件夹叫“final_final_20211109”。里面那个几千行的 C 文件让我印象很深模块之间靠全局变量互相透消息一个寄存器配置函数写了 500 多行每行配置后面跟着一句“根据手册第 XX 页”。我花了一个通宵把代码结构理清楚第二天第一件事就是跟领导摊牌——这不是代码能力的问题是整个开发方式的问题。嵌入式软件开发真的到了和古法编程彻底说再见的时候了。这里说的“古法编程”不是指某个具体技术栈落后而是指一套完整的、曾经非常奏效的开发习惯直接怼寄存器、全局变量满天飞、printf 打天下、一个文件写到底、没有版本控制、没有自动化构建、没有测试的概念。这套打法在马达控制、小家电、8 位单片机的年代是合理甚至是唯一的选择。但今天再拿这套思路去做带联网、带 UI、带多任务调度的产品翻车只是时间问题。这篇文章不打算做那种“旧东西全是垃圾、新东西全是真理”的二极管批判。我想把这些年看到的、踩过的、改进过的东西摊开来讲清楚古法编程到底长什么样它为什么曾经成立又为什么在今天不再成立以及认真告别它之后你的工作流应该变成什么样子。不管你是正在维护老项目的工程师还是准备投嵌入式软件开发面试题的应届生这篇文章应该都能提供一点不太一样的视角。1. 古法编程的真实形态我从一个祖传工程说起1.1 我见过的最典型古法代码很多刚入行的朋友可能以为“古法编程”是个夸张的修辞其实不是。我见过最典型的祖传代码长这样#define REG_PLL_CFG (*(volatile unsigned char *)0xE0002030) #define REG_MODE (*(volatile unsigned char *)0xE0002034) #define REG_STATUS (*(volatile unsigned char *)0xE0002038) void whole_board_init(void) { unsigned char i; REG_PLL_CFG 0x87; // 根据手册p123 锁定PLL REG_MODE 0x01; // 根据手册p125 切到运行模式 for (i 0; i 100; i) __asm(nop); if (REG_STATUS 0x02) { // TODO: 状态不对再复位一下 } }你看地址、掩码、时序参数全部硬编码连个注释都只是“根据手册第几页”。这样的代码不是不能跑而是只有写它的人能维护——甚至写它的人隔三个月再看也得对着手册翻半天。更麻烦的是它根本没法测试一改动就提心吊胆因为一个字节的偏移算错整个板子可能直接起不来。1.2 古法开发模式的共性清单我总结过古法编程通常在以下几个层面同时“古”硬件操作层面直接用寄存器地址宏很少经过硬件抽象层外设初始化代码和业务逻辑混在一起。工程组织层面一个大文件容纳“初始化、主循环、中断、延时、业务状态机”模块之间通过全局变量和 extern 声明通信。构建与版本层面要么没有版本控制要么 SVN 拉到最新备份靠文件夹命名比如“final_final_20211109”。调试手段层面全靠串口 printf 和示波器程序卡住了就加打印没有 Trace没有断点分析日志没有等级概念。测试与交付层面合不合格看“功能能跑就行”没有单元测试没有自动化回归全凭经验评估改动风险。这一套东西放在一起形成一个很稳定的“舒适区”。你会觉得反正电路板在我手里跑一跑就知道结果写测试、建工程、拉分支都是浪费时间。问题是这个“舒适区”本质上是用个人记忆和线下试错替代了工程体系。1.3 一个容易忽略的事实古法在当年是合理的批古法之前我得先替它说句公道话这套玩法在它所属的年代是效率最高的方案没有之一。8 位单片机年代片上 Flash 只有几 KBRAM 按字节数着用编译器优化能力也有限。你让工程师在这种环境里做分层抽象、跑个 RTOS、塞进去一个日志框架根本不现实。寄存器直操作反而是最透明、最可控的方式——每一行代码对应几个指令周期性能如何心里有数。再说了那时候产品功能单一一个遥控器、一个电饭煲控制板代码量就是几千行一个人从头负责到尾“变量名守恒”全局通信这套朴素的规则完全够用。所以批判古法编程不该批判“认真研究寄存器”的行为那永远是嵌入式的核心素养之一。该批判的是那种“只靠寄存器、只靠全局变量、只靠 printf、只靠人工测试”的整体方法论。前者是基本功后者是刻舟求剑。2. 为什么“当年能跑”的方法今天逐渐跑不动了2.1 产品复杂度已经量级跳跃今天随便一个家用产品都可能是这套配置主控 MCU 跑到几百兆带 Wi-Fi/蓝牙协议栈外挂彩屏和触摸要做 OTA 升级还要跟手机 App 通信。我们从五六千行“小逻辑”直接跳到一二十万行的“小系统”古法的记忆式全局管理就彻底失效了。我举个真实例子。之前某个项目里红外遥控子模块和电源管理子模块共用一个全局变量做状态同步。单独看每个模块都正常联调时发现偶发性死机。查了三天最后定位到是中断里的电源管理代码在改同一个变量破坏了红外解码的时序。用现代的说法这就是典型的共享资源竞争问题一个信号量就解决了。但在古法代码里你不会有“临界区”这种概念只会觉得是玄学问题。复杂度一旦上去靠脑子记住所有变量、时序和依赖关系是不可能完成的任务。你需要的是抽象、隔离和边界而不是更厚的全局变量表。2.2 团队协作方式从单人作坊变成了流水线以前的嵌入式开发经常是“一个人管到底”。需求、设计、编码、调试、出厂都是同一双手。现在不一样了一个产品可能是驱动的同事写底层应用的同事写业务云端的同事管协议测试的同事做验证大家可能还在不同的办公地点。这种节奏下如果你交出去的是一个大而全的单文件工程别说协作连代码评审都无从下手。我见过最崩溃的评审现场打开一个 4000 行的文件从上往下翻没有任何函数注释变量名是 a、b、tmp评审委员问“这个模块的输入输出是什么”作者自己都要想半天。这种代码没法并行开发也没法做增量交付所有人在同一堆全局变量上博弈改了就是互相踩脚。协作时代需要的是清晰接口、独立模块、可测试单元——这些恰恰都是古法编程最不擅长的事。2.3 调试手段的瓶颈printf 已经不够用了我不是要全盘否定 printf 调试它永远是嵌入式调试的重要手段。问题是仅靠 printf 应对不了现代嵌入式系统的两个特征并发和时序。多任务系统里打日志本身就会改变执行时序有些 Bug 因为加了打印就不复现把打印删掉又出现——俗称“Heisenbug”。实时性问题更是如此两个任务之间的时序竞争你靠串口打印基本看不出来需要靠逻辑分析仪、Trace 工具、甚至硬件的 ETM 跟踪才能抓到。现代调试工具链里很多 MCU 已经支持通过 SWD 接口做实时变量追踪、指令流回放你可以在不打断程序的情况下看内部状态变化。这些手段你在古法工作流里可能听都没听过。还有一个让我很感触的细节现在的高性能调试器已经能直接把 MCU 的运行轨迹存下来回放定位“哪个中断占了太长时间”这类问题非常快。但如果你还停留在“板子跑挂了就加打印重刷”的阶段这些工具的价值你完全体会不到。2.4 人才和面试评估体系也变了你看一眼现在的嵌入式软件开发面试题就知道考察重点早就变了。以前可能会问你某个寄存器的默认值是多少、某个外设的引脚怎么配现在更多是问你如何设计一个低耦合的驱动层任务间通信怎么避免数据竞争怎么保证升级失败还能回滚怎么在 CI 里跑固件单元测试这些题目背后体现的是同一种要求你能不能在代码还没上板子之前就通过架构设计、静态分析和自动化测试把大部分问题解决掉。古法编程强调的“板子上见真章”当然还有价值但它只是最后一环不再是整个开发流程的主角。换句话说嵌入式行业对工程师的要求已经从“玩转一个芯片”升级成了“系统化地做出一个可靠产品”。这个转变不是某个公司搞内卷是整个产业从“能跑就行”走向“又快又稳又可控”的必然。3. 真正该做的不是丢掉基本功而是升级这套工作流3.1 硬件抽象层从寄存器直操作到 HAL/LL 是第一步告别古法第一步不是去学一堆花哨框架而是把硬件操作从“直接怼寄存器”升级到“通过抽象层访问”。现在的芯片厂商基本都提供了两套库HAL硬件抽象层和 LL底层库。HAL 偏向功能封装GPIOSet 一个函数搞定引脚配置PWM 启动一个函数搞定波形输出LL 则更接近寄存器操作但做了合理封装性能和可读性都不错。我的建议是芯片初始化、外设配置这类“低频操作”用 HAL 就好可读性强不易出错官方维护还在持续修 Bug。定时器中断、高速通信这种对延迟敏感的逻辑用 LL 或者直接操作寄存器但一定要加注释解释为什么“这里不能走 HAL”。更进一步的在自己的应用层再包一层把硬件厂商的库再隔离开。这样以后换芯片平台时业务代码基本不需要改。这个动作看起来只是“换一种写法”实际上是把硬件依赖逐步从业务代码里剥出去。原来改动一个 GPIO 要翻一堆手册现在你只需要改设备树、改配置文件或改一个 io_config 结构体里的字段。3.2 工程结构告别单文件走向模块化分层我见过太多人包括以前的我都喜欢把代码堆在一个“mian.c”里理由无非是“这样找起来方便”。但工程一旦超过一万行单文件的维护成本就会指数级上升。我现在习惯的工程结构大概是这样的project/ ├── application/ # 业务逻辑层任务、状态机、协议处理 ├── bsp/ # 板级支持包具体板子的初始化、外设配置 ├── drivers/ # 芯片外设驱动封装厂商库或寄存器操作 ├── middleware/ # 中间件RTOS 封装、日志、环形队列、加密等 ├── os/ # 操作系统内核或移植层 ├── tests/ # 单元测试与硬件在环测试 ├── docs/ # 设计文档、接口说明 └── build/ # 构建产物不入库分层不是让你把简单事情搞复杂而是让每一层都有清晰边界驱动层不知道业务逻辑业务层不直接碰寄存器中间件的接口保持稳定。这样做的直接好处是新同事入职后不用从头到尾读代码只读 application 层就能理解产品逻辑电控调整引脚时只改 bsp 和 drivers不会动到业务代码。3.3 版本控制从“final_final”进入 Git 时代把代码从复制备份改成 Git 管理是我认为性价比最高的转型动作没有之一。哪怕其他现代化手段都暂缓这一条也应该立刻做。Git 的价值在于它强逼你回答三个问题改了什么为什么改和谁一起改配合 commit message 规范你能在三个月后很快回忆起来某次改动的动机。配合 Tag 和分支策略你能轻松做到线上固件和源码版本的对应。出了问题还可以用 git bisect 二分定位哪次提交引入的 Bug。我看到有些老同事还在用 SVN其实 SVN 也还行但 Git 的本地分支、随随便便开实验分支、离线提交这些特性对嵌入式开发特别友好。你不必一开始就用 GitHub 那套复杂的 GitFlow单人项目用 master task 分支加上每个功能一个分支合并前 code review就已经是巨大进步了。3.4 调试追踪从 printf 到多级日志和 Trace 工具把调试从“全靠串口打印”升级为“系统化日志 硬件 Trace”是很多嵌入式工程师最容易忽略的一环。原因很简单printf 的即时性最强改起来也最快但它的信息密度和安全性都很差。我建议至少做两件事引入带等级的日志组件比如区分 ERROR、WARN、INFO、DEBUG 四级支持由配置开关控制输出等级。日志不要直接写在业务代码里满天飞统一走一个接口比如 log_debug(temp: %d, temp) 底层可以串口输出、文件存储、或者空中转发。另外有条件的话建议投入一点成本把硬件 Trace 工具用起来。现在不少调试器和 IDE 已经集成 MCU 实时跟踪功能比如 ARM Cortex-M 的 ETM/ITM 可以做到不打断程序的情况下输出诊断信息。你会在不知不觉中接收到 CPU 的实际执行情况而不是靠 printf 打断节奏。3.5 测试从“板级人工验证”到单元测试和 CI一说嵌入式自动化测试很多人的第一反应是“我们硬件资源太紧张跑不起”。其实这里要区分两类测试目标板上的测试硬件在环测试确实需要硬件资源。开发机上的单元测试HOST 测试完全不依赖板子。单元测试的做法是把业务逻辑和硬件抽象分开然后在 PC 上编译执行测试用例。比如你写了一个 PID 控制器、一个 Modbus 报文解析器、一个 OTA 包校验函数这些都可以在 PC 上建测试工程喂入边界数据断言输出是否符合预期。大部分嵌入式逻辑错误根本不需要上板在这一步就能拦住。再进一步就可以把单元测试接入 CI。现在的 Git 托管平台都支持 CI你推一条代码云端自动拉代码、编译、跑静态检查、跑单元测试几分钟内出结果。以前上板才能发现的低级错误现在提交阶段就会被代码检查工具标记出来。这对团队质量的影响是立竿见影的。4. 不同处境的人转型打法是不同的4.1 正在维护存量项目的渐进式改造而不是推倒重来如果你手里是一个已经在量产的老项目我的第一建议是别轻易推倒重来。产品在线上跑得好好的业务流程已经经过市场验证贸然重写等于用几百万的市场信誉当赌注。但“不重写”不等于“不改造”。存量项目的现代化转型可以按下面这个顺序来先把代码完整纳入 Git打上当前生产版本的 Tag。这一步不需要动代码只是保证“可回溯”。建立自动化构建脚本把以前“手动点编译、手动烧录”的流程固化。从代码中抽出最核心、最稳定、最容易出问题的模块比如通信协议解析、状态机、算法写成独立单元并补单元测试。每改一批代码就围绕新增或变更部分跑一次回归。哪怕只是“功能感觉没坏”也好过完全没基线。在重构过程中逐步修掉跨模块全局变量用接口函数和任务间通信替代。说实话这条路比较漫长可能要走一两个版本迭代才能见到明显成效。但它的风险最低符合“小步快跑”的逻辑。我见过不少团队用这种“边量产边重构”的方式在不熬夜、不加班的节奏下把老代码慢慢洗成有接口、有注释、有测试的状态。4.2 正在启动新项目的第一天就把新方法立起来新项目没有历史包袱千万不要把以前的老毛病带进新代码。我给新项目的建议是从立项第一天就定好四条纪律新代码必须有硬件抽象层应用代码不允许直接出现寄存器操作。所有对外接口写清楚输入输出和错误码注释要解释“为什么”而不是“是什么”。每个核心模块至少有一个单元测试文件随着功能扩展同步补齐。提交信息遵循规范新功能、修复、重构这些类型在 commit message 里一眼能分辨。新项目的优势在于没有兼容性负担你可以在这些纪律下大胆设计。哪怕一开始多花一点时间搭架子后期迭代的速度会明显快过“上来就写功能、写到一半发现结构不对”的对手。4.3 从 MCU 走向嵌入式 Linux一次工作流的整体跨越如果你所在的产品线已经从裸机 MCU 走向了嵌入式 Linux哪怕是带 MMU 的高性能 MCU 跑 Linux那相当于工作流的一次整体升级。Linux 环境下你面对的不再是单文件、单进程、共享内存的一亩三分地而是进程、线程、虚拟内存、文件系统、设备树、根文件系统这一整套体系。这一步给我的冲击特别大。以前我在 MCU 上维护一个 index 变量觉得天经地义到了 Linux 应用开发里如果还把所有状态放在全局变量里早晚被多线程竞争教做人。Linux 下的嵌入式开发你基本离不开这些工具Yocto/Buildroot定制根文件系统。设备树 DTS描述硬件资源。systemd管理服务启动和依赖。GDB在目标机上做断点调试。Valgrind/ASan排查内存泄漏和越界。单元测试框架C 语言社区常见的选择有 Ceedling、Unity、CMock 等。这个转型不只是“学一个新工具”而是把整个脑袋里的“嵌入式开发方法论”进行一次刷新。好消息是只要你前面已经把模块化、抽象层、版本控制、测试这些基本功养成了Linux 只是把这些做法放大到更广阔的平台上。4.4 学生和转行者面试题早就不是“背寄存器”了每次看到有应届生抱着 8051 开发板把“定时器工作模式寄存器”背得滚瓜烂熟我都挺心疼的。不是说 8051 不好而是现在嵌入式软件开发面试题考查方向已经完全不同了。企业招人要的是能直接进入现代工程体系的人。给准备入行嵌入式软件开发的朋友几条建议基础课程要有C 语言指针与内存模型、数据结构与算法、计算机组成原理、操作系统原理。这是下限。至少在开发板上完整做过一个带 RTOS 的项目理解任务、信号量、消息队列、内存管理的实际应用。这比把外设寄存器配置背得再熟都有用。一定要会 Git要有把代码托管到远程仓库的习惯。面试官看你简历里的 GitHub 仓库比看八百行“项目经验”有说服力。尝试在自己做的小项目里写单元测试哪怕是简单的断言式测试。这能让你快速理解什么叫“可测试的代码”。如果你的目标公司做嵌入式 Linux 方向就提前把 Linux 应用编程、驱动框架的基本概念过一遍。说白了“古法编程”对应的是经验驱动、体力驱动、点对点试错的开发模式而今天的企业真正需要的是工程化能力。知识可以积累芯片可以换但如果没有工程习惯换什么技术栈都会重走老路。5. 告别古法路上的三个误区以及我的几条实用建议5.1 误区一把“用了新工具”当成“完成了现代化”我在团队里见过一个典型场景项目经理花了两天时间把编译系统从 Makefile 换成了 CMake然后宣布“我们完成现代化了”。实际上代码还是那个 3000 行的单文件还是全局变量满天飞还是没人写测试。工具升级了方法论没变浪费了时间但没解决任何根本问题。现代化是一整套动作抽象层、模块边界、版本控制、测试基线、可回溯的构建。这套东西少了任何一环另外几环的效果都会大打折扣。拿 CMake 这种构建工具来说它只是个放大器——你的工程结构合理它能让你构建更省心你的工程结构混乱它只会把混乱更快地暴露出来。5.2 误区二无视硬件资源约束过度抽象我也要泼一盆冷水不是所有项目都必须上 RTOS、必须做多层抽象、必须在 8KB RAM 的 MCU 里跑一个完整框架。硬件资源就是硬件资源这是嵌入式开发和互联网应用开发最大的不同。你在一颗 16MHz、2KB RAM 的芯片上做小家电控制非要去套一个设备树 动态内存管理那不是现代化那是画蛇添足。我自己的判断标准是当代码规模超过一个人能完全理解的上限或者多个任务存在错综复杂的时序依赖再考虑上抽象和 RTOS如果产品就一个状态机、跑一个循环把状态机写得清清楚楚、用 switch-case 把状态迁移列出来这本身就很现代化。现代化不等于“堆工具”而是“问题复杂度与解决方案复杂度匹配”。宁可代码写简单也不要为了显得高级去引入复杂机制。5.3 误区三工具链引入很快流程配套跟不上还有一个常见的翻车点代码评审、CI、测试流程确实引入了但团队没有养成对应的协作习惯。CI 配置好了没人看红绿灯代码评审变成走个形式单元测试跑挂了先跳过再修。这种情况持续时间一长新流程就跟不存在一样大家退回古法然后得出结论“新方法论都是虚的”。流程要想落地靠两样东西一是让结果可见比如 CI 状态直接挂在办公室屏幕上红了就停下来修二是把习惯内化到周计划里比如每周安排半天做重构和补测试而不是等模块堆满再亡羊补牢。5.4 几条我一直在用的实操建议这段话算是我在多个项目里反复验证过的小经验分享给你接手老代码第一步永远是建 Git 仓库并打 Tag哪怕代码再烂也要保证能回滚。新写的每个驱动函数都在头部注释里写明“输入参数是什么、返回值代表什么、调用它的时机限制是什么”。中断处理函数尽量短把复杂处理和业务决策放到主循环或任务里去不要在中断里写业务。能用一个状态机表达的逻辑不要再用一堆 if-else 嵌套处理。状态机代码可读性和可测试性都好得多。日志信息要带模块标签和时间戳不然出了问题你只能猜是哪个模块打的。多花点钱买个好用的调试器省下的都是排查 Bug 的时间。我自己的体会是告别古法编程并不是否定过去那些经验而是把“认真读手册、搞懂时序、控制资源”的精神保留下来再用一套更高效的系统去承接。工程习惯这东西越早建立后面走的弯路就越少。有时候你回头看三个月前自己写的代码已经想重构再回头看三年前按照古法写的代码恐怕只能庆幸当时没出什么大事故。嵌入式行业还会继续往前走芯片越来越强产品越来越复杂工程方法只会越来越重要。在这个时间点上“彻底说再见”不是矫情是真的该往前走了。
返回列表