做飞控开发这几年FMT算是把我对“开源飞控工程化”的认知拉高了一个档次的项目。很多人第一次接触FMT是先被它的界面、仿真、控制算法吸引真正上手编译时才发现它底层那一套基于RT-Thread实时操作系统加SCons构建系统的组合才是整个项目能保持“模块清晰、跨平台、可复用”的基石。这篇文章不准备讲怎么调参、怎么飞而是把FMT这套构建体系的来龙去脉拆开揉碎讲清楚RT-Thread在飞控里到底承担了什么、SCons为什么能替代我们熟悉的Makefile/CMake、以及从零编译一个FMT固件时背后到底发生了什么。适合正在学FMT、想改底层驱动、或者打算基于FMT二次开发的工程师看完至少能少走两三个月的弯路。1. FMT飞控框架与RT-Thread的深度绑定1.1 先看FMT在软件架构上做了哪些事FMTFirmament是一套面向教育、科研和行业应用的飞控系统它在软件层面做的事情可以粗略分为四层最底层是板级驱动负责访问传感器IMU、气压计、磁力计、执行机构PWM电调输出、通信外设UART、CAN、USB往上是实时操作系统层负责线程调度、内存管理、设备注册、中断处理再往上是飞控中间件包括uORB消息总线、参数系统、日志系统最上层才是具体飞行逻辑也就是姿态解算、姿态控制、位置控制、导航等。这里最关键的一个设计决策是FMT没有像很多传统飞控那样自己写一个超级循环来“轮流处理”所有任务而是直接站在RT-Thread这颗RTOS的肩膀上把所有功能拆成独立线程用消息队列和信号量来做线程间通信。这个决策从根上决定了FMT的代码组织方式和实时性表现。为什么非要用RTOS飞控是一个典型的强实时系统。IMU数据可能以1kHz的频率到达姿态控制环需要以500Hz甚至1kHz的频率运行而日志写入、无线通信、地面站指令处理这些任务又没那么紧急。如果不用RTOS就得靠一个裸机大循环加中断标志位来硬凑时序工程复杂度一上去就非常难维护。而且不同传感器、不同板子之间的时序差异很大裸机方案几乎每一块板子都要重写调度逻辑。用了RTOS之后每个功能模块就是一个独立线程优先级、时间片、栈大小都交给系统统一管理移植成本断崖式下降。1.2 为什么FMT选RT-Thread而不是NuttX或FreeRTOS飞控圈里提到RTOS多数人第一时间想到的是NuttX因为PX4和ArduPilot都有对应的移植版本。FreeRTOS也很流行但主要靠社区和厂商适配来支撑。FMT选择RT-Thread仔细想想是有它自己的考量的。RT-Thread最突出的优势是它“面向应用”的组件生态。它自带一个名叫MSHModular Shell的命令行组件类似Linux的shell你在串口终端里敲命令就能查看线程状态、访问设备、调用函数这在调试飞控时太好用了。比如怀疑传感器数据异常直接在MSH里执行一条命令挂一个内部命令马上就能看到原始读数不用反复烧录代码。还有它完善的设备驱动框架I2C、SPI、UART、DMA、PWM这些总线和外设全部抽象成了标准设备对外暴露统一的操作接口。开发者写驱动时只需要实现底层操作函数然后注册到设备管理器上层调用open/read/write/ioctl就行了这套模型和Linux的设备驱动思想非常接近。另一个被很多教程忽略的点是RT-Thread拥有包管理工具Env和RT-Thread Studio。虽然FMT项目本身不强制用Env但FMT里大量的第三方组件比如各种传感器驱动、显示驱动、控制台工具都以类似RT-Thread软件包的方式组织这种天然互通性让FMT在支持新硬件时能快速复用RT-Thread社区里的驱动。相比之下FreeRTOS只有内核剩下一切都是自己拼装NuttX功能全但复杂度高、学习曲线陡而且它的构建系统自成一套二次开发需要额外适应。RT-Thread正好卡在“足够轻量”和“设备驱动完善”之间这个生态定位让它很适合作为飞控这种需要外设驱动的实时系统底座。1.3 RT-Thread在FMT运行时的具体分工我用一个实际例程来说明。假设FMT跑在一个基于STM32F4的飞控板上系统刚上电时FMT的启动代码会先完成RT-Thread内核的初始化然后创建一个名为fmt_main的线程。这个线程会去加载参数、启动传感器、完成校准接着继续创建姿态控制线程、位置控制线程、日志线程、通信线程等。线程之间的数据交互走的是uORB消息总线。比如IMU驱动线程以1kHz频率读取MPU6500的加速度和角速度数据封装成一条uORB消息发布到总线上姿态解算线程订阅这个topic计算出姿态四元数再发布到另一个topic控制线程订阅姿态topic结合控制参数计算电机输出再把输出值写入PWM设备。整个数据流非常清晰每一条链路的延时和抖动都被RT-Thread的优先和调度策略严格控制。此外RT-Thread还承担了时间基准和任务监控。FMT内部很多算法需要精确的系统时间戳RT-Thread的tick时钟和硬件定时器提供了毫秒甚至微秒级的时间参考。它还提供了irtinspect RT-thread机制可以通过命令行查看每个线程的CPU占用率、栈使用情况这在排查“哪个任务吃掉太多CPU”或者“哪个线程栈溢出”时是神器。可以说RT-Thread不只是FMT的“运行底座”它还深度参与了FMT的日常调试和故障分析。2. 为什么FMT选择SCons作为构建系统2.1 SCons的基本工作原理SCons是Python编写的软件构建工具它和GNU Make、CMake这类工具最大的区别在于“构建脚本本身就是Python程序”。Make的Makefile拥有一套独立的语法变量、规则、隐式推导还有烦人的Tab键规则CMake发明了CMakeLists.txt语法也算简洁但新版本频繁变更大量变量名和行为在不同版本之间并不一致。SCons选择直接用Python这意味着你可以在构建脚本里写任意Python逻辑做字符串处理、读注册表、调用外部命令、根据条件动态改变编译选项。SCons的工作流大致是这样的有一个入口SConstruct文件它读取构建环境配置然后遍历整个项目目录找到所有SConscript文件每个SConscript文件描述当前目录下有哪些源文件需要编译、编译成什么类型的目标。SCons会对这棵树进行依赖分析生成一张依赖图只有源文件或构建脚本发生变化时才会触发相关目录重编。开发者改动一个头文件SCons会根据#include关系自动找出所有依赖这个头文件的重编源文件这个“自动扫描头文件依赖”的能力在C/C工程里比Makefile手写依赖省心得多。下面是一段简单的SConscript文件感受一下它的风格。# 将当前目录下的hello.c编译成名为hello的可执行文件 env Environment() env.Program(hello, [hello.c])再比如FMT里编译某个传感器驱动模块会写成这样Import(RTT_ROOT) from building import * cwd GetCurrentDir() src [drv_mpu6500.c] CPPPATH [cwd, str(Dir(#))] group DefineGroup(Driver, src, depend[FMT_USING_IMU_MPU6500], CPPPATHCPPPATH) Return(group)DefineGroup这个函数来自RT-Thread提供的构建辅助模块它会把当前目录的源文件打包成一个“组”并可以声明这个组依赖哪个配置项FMT_USING_IMU_MPU6500。配置项为假时整个组都不参与编译。这就是SCons RT-Thread这套体系里面“条件编译”的实现方式不用把你需要的源文件手动筛来筛去只要配置项开关SCons会自动决定编译集合。2.2 SCons相比Makefile/CMake在飞控场景的优势飞控项目源文件数量多、平台交叉编译频繁、第三方驱动更新快这些特性正好是SCons的舒适区。首先飞控的工程结构通常是一棵树应用层、中间件、驱动层、RTOS内核每层下面又有大量子目录。Makefile想管理这种结构要么递归make层级间变量传递麻烦要么把所有源文件展开在一个顶层Makefile里结构不清晰依赖混乱。CMake虽然能通过add_subdirectory管理子目录但变量作用域、target传递也有一定复杂度。SCons的SConscript天然就是为“递归构建”设计的每个目录自带构建描述父目录导入子目录的构建结果变量和环境的继承、覆盖逻辑很清晰。其次SCons对工具链切换的适应能力很强。FMT支持GCC、Clang、IAR等多种编译工具链支持不同芯片架构。换工具链时不需要改整个构建脚本只需要修改环境变量里CC、CXX、AS、LINK这些编译器名称SCons就能自动切换。而且SCons内置了很多编译器的行为规则比如知道GCC的-c参数生成.o文件、知道-o指定输出文件名这套“内建规则”免去了开发者手动编写大量编译命令。再一个很多人容易忽略的点SCons构建脚本用Python编写天然跨平台。Windows、Linux、macOS都能跑开发者在Windows下用IDE写代码在Ubuntu服务器上做持续集成编译脚本保持一致不会出现Windows下换行符和Linux不同导致Makefile崩掉的尴尬。FMT的持续集成流程里SCons脚本不需要任何平台适配改动就能在两个系统上跑同一套构建流程这在实际工程维护中节省了大量精力。2.3 SConstruct、SConscript、rtconfig.py的配合FMT项目里SCons相关的文件主要有以下三类第一类是SConstruct位于项目根目录是整个构建系统的入口。它的职责是初始化构建环境包括指定编译工具链、设置全局宏定义、引入RT-Thread构建支持模块然后调用SConscript递归构建子目录。第二类是SConscript分散在各级子目录。每个SConscript描述当前目录下哪些源文件要参与编译、需要哪些头文件路径、依赖哪些配置开关。FMT里主要模块如src/module/controller、src/module/sensor、src/driver下都有各自的SConscript打开文件就能看到这个模块由哪些c文件组成。第三类是rtconfig.py这是RT-Thread/SCons体系里最关键的配置文件它定义了交叉编译器前缀、CPU类型、编译选项等。FMT中通常每个板级工程有一个对应的rtconfig.py内容大概长这样import os # 工具链前缀比如arm-none-eabi- CROSS_TOOL gcc PLATFORM gcc EXEC_PATH /opt/gcc-arm-none-eabi/bin RTT_ROOT os.path.normpath(os.getcwd() /rt-thread) PREFIX arm-none-eabi- CC PREFIX gcc CXX PREFIX g AS PREFIX gcc AR PREFIX ar LINK PREFIX gcc CFLAGS -mcpucortex-m7 -mthumb -mfpufpv5-d16 -mfloat-abihard这里的RTT_ROOT告诉构建系统RT-Thread内核源码在哪里EXEC_PATH指定交叉编译链的安装路径CFLAGS里是芯片架构相关的编译选项。换一块芯片比如从F4换到H7主要就是改-mcpu、-mfpu这些选项以及链接脚本路径。对多数开发者来说平时改得最多的就是这个文件。3. FMT里RT-Thread与SCons的交互机制3.1 FMT如何使用rtconfig.h控制组件编译RT-Thread和FMT的“契合点”除了内核本身还体现在一套基于配置头文件的组件开关机制。构建时rtconfig.h一般在板级工程目录下决定哪些模块和驱动被编译进固件。例如FMT的board目录里每个板型都有对应的配置片段// 使能UART2设备 #define RT_USING_UART2 // 使用FMT的IMU驱动 #define FMT_USING_IMU_MPU6500 // 使用FMT的Baro驱动 #define FMT_USING_BARO_BMP280 // 使能MSH组件 #define RT_USING_COMPONENTS_INIT #define RT_USING_MSHSCons在解析SConscript时会读取这个rtconfig.h里的宏利用DefineGroup(..., depend[FMT_USING_IMU_MPU6500], ...)的机制判断是否将某个组加入编译。这种方式比CMake的option()更贴近嵌入式工程师的习惯所有配置集中在一个极简头文件里IDE里改宏或者命令行用脚本修改都行而且构建脚本完全不需要重新解析复杂逻辑。同时FMT用了RT-Thread的kconfig体系辅助生成配置。menuconfig一类工具能把所有开关做成图形化菜单生成最终的rtconfig.h。不过多数用户直接手动改头文件就够了。我的建议是你在FMT里加新驱动时先确定这个驱动依赖哪些宏再修改对应rtconfig.h最后重新执行scons通常就能编译进去。3.2 增量编译与依赖扫描的实际表现SCons另一个让我很受益的地方是它的增量编译算法。传统Makefile经常遇到“明明改了头文件但模块没有重编”的问题原因多半是依赖关系没有正确声明。SCons默认会在编译每个C文件时扫描它的#include列表自动建立一个“源文件 → 头文件”的依赖图。只要头文件发生变更所有包含它的C文件都会被自动重新编译。飞控这种项目里头文件之间层级多、依赖关系复杂SCons的自动扫描机制确实帮我省了不少事。比如我改了module_common.h里的一个枚举定义以前用Makefile时需要手动make clean再全量重编那编译一次动辄好几分钟在FMT的SCons体系下它自己就找到依赖这个头文件的几十个文件只重编它们几分钟的事情压缩到几十秒。不过有一点要提醒SCons的扫描依赖通常基于当前编译环境如果你用了一些不规范的宏拼接#include头文件它可能会漏掉。这时候可以在SConscript里手动添加Depends来补上依赖。SCons编译时还有缓存机制。FMT支持配置编译缓存目录ccacheSCons天然能配合ccache使用。默认情况下ccache按编译命令参数做缓存匹配SCons生成的编译命令稳定、参数固定缓存命中率很可观。大型重构之后全量编译只要缓存目录还在第二次构建能明显更快。3.3 通过SCons执行非编译类任务很多人以为SCons只是“编译工具”实际上FMT大量使用SCons来执行和编译无关的工程任务。比如scons -c可以做清理会在多个目录里删除生成物更典型的是固件打包和上传FMT的SCons脚本里有构建fmt_firmware.bin或.elf之后自动调用arm-none-eabi-objcopy生成bin、再用python脚本计算CRC、填充固件头等步骤这些在传统Makefile里需要额外写很多脚本SCons因为可以直接os.system()或者用Python子进程做起这种“编译后处理”干净利落。FMT还提供--apply、--config等SCons扩展命令用于把某个板级配置复制成当前配置或者调用menuconfig生成新配置。例如执行scons --applyFMTA2会把board/FMTA2下的头文件、链接脚本、rtconfig.h等一次性拷贝到工程根目录再编译时直接使用这套配置。SCons在这里承担了“工程管理命令”的角色这是它Python脚本化带来的直接收益。4. 实操基于FMT的RT-Thread SCons全流程编译4.1 环境准备与工具链要亲手编译一个FMT固件第一步是准备环境。我以最常见的Linux开发机为例需要的东西有四样Python 3.x且python3命令已加入PATHSCons工具执行pip install scons安装或者用发行版的包管理器安装arm-none-eabi交叉编译器建议版本10以上直接下载官方工具链或者通过apt安装git用来拉取代码FMT源码可以从官网或代码托管平台拿目录结构大致包含firmware、src、board、rt-thread、build等。rt-thread目录就是RT-Thread内核源码FMT把它作为子模块或直接内嵌在项目里保证版本匹配。装好后来一句话快速验证环境scons --version arm-none-eabi-gcc --version两个都有输出就可以拉代码编译了。4.2 编译一块真实飞控板的完整命令假设我要编译FMT的FMTA2板级固件全过程如下# 拉取代码如果是git仓库 git clone https://github.com/Firmament-Autopilot/FMT-Firmware.git cd FMT-Firmware # 应用FMTA2板级配置 scons --applyFMTA2 -j4 # 执行编译-j4表示4线程并行 scons -j4第一次执行会输出一大串编译信息从rt-thread内核源码开始编译然后是驱动、中间件、控制模块最后链接生成elf文件。编译完成后在输出目录或工程根目录下会看到对应的固件文件比如fmta2.bin、fmta2.elf。fmta2.elf用于调试器J-Link/ST-Link直接烧录和断点调试fmta2.bin用于通过Bootloader或者ST-Link命令行烧写。--apply命令是FMT特有的它把board/FMTA2下的rtconfig.h、链接脚本、板级初始化文件复制到基准配置目录。如果只改代码不换板子重复编译只需要执行scons -j4。注意--apply会覆盖当前配置万一之前手动改过rtconfig.h执行前先备份。4.3 如何新增一个传感器驱动并编译进固件这一步是很多人关心的高频需求。以新增一个I2C接口的气压计为例大致步骤如下第一步在src/driver/baro目录下新建drv_ms5611.c和drv_ms5611.h按照FMT已有的驱动风格实现设备初始化、数据读取、注册到传感器hub。第二步修改该目录下的SConscript把新的c文件加入src列表src [drv_bmp280.c, drv_ms5611.c]第三步在对应板级配置的rtconfig.h里加上宏开关#define FMT_USING_BARO_MS5611第四步重新编译scons -j4SCons会扫描到新的SConscript和源文件自动编译并链接到固件。因为SCons是Python脚本它会在每次构建时重新读取SConscript所以不需要执行make clean之类的操作。整个流程里最容易被卡住的是传感器对应的配置宏和SConscript里的depend没有对齐。如果你把drv_ms5611.c加进了src但SConscript里的depend还是只依赖FMT_USING_BARO_BMP280SCons会认为新源文件和任何配置都不关联压根不会编译。这个“宏依赖-源文件关联”的逻辑是FMT这个构建体系最需要先搞懂的地方。4.4 链接脚本和内存布局的调整飞控板子换芯片型号、改Flash/RAM分区或者外扩了SDRAM都要动链接脚本。FMT的链接脚本在不同板级目录下通常以.ld或.lds结尾。以STM32H7为例链接脚本里会定义MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 1024K RAM (xrw) : ORIGIN 0x24000000, LENGTH 512K }如果你的板子Flash只有512K就把LENGTH改成512K如果RAM起始地址不同就改ORIGIN。改完链接脚本后直接scons -j4重新链接即可。这里容易踩的坑是SCons不一定每次都重新链接如果ld脚本变了但源文件没重编旧的目标文件还会继续用。建议改链接脚本后把build目录删掉再全量编译一次或者touch一下主SConscript强制整个工程重新链接。5. 构建常见问题与调试经验5.1 编译报“找不到Python模块”或“SCons版本太低”FMT依赖的SCons版本一般会在README或构建脚本里标明。如果执行scons时提示版本过低需要升级pip install -U scons如果提示缺少building模块多半是RT-Thread的构建辅助脚本路径没被引入。排查方法是看SConstruct开头有没有正确执行sys.path插入操作把rt-thread/tools目录加进来。FMT正常拉取的源码应该自带这个逻辑但如果你手动调整过目录结构容易破坏相对路径。建议不要随意挪动rt-thread目录和根目录的层级关系。5.2 增量编译“不生效”或“乱重编”这类问题通常分两种。第一种是该重编的没重编多半是依赖扫描漏掉了某些头文件典型场景是源文件里通过宏生成头文件名#if USE_VERSION 2 #include config_v3.h #else #include config_v2.h #endifSCons对这种情况的推导经常不准解决方式是在SConscript里手动声明依赖env.Depends(drv_foo.c, [config_v2.h, config_v3.h])第二种是不该重编的却重编了通常和构建路径变化有关。SCons在对比源文件和目标文件的依赖时如果发现某个头文件的绝对路径变了比如你用软链接引用工具链头文件、或者挂载路径变化它会认为整个依赖链都失效因此大面积重编。解决办法是尽量保持构建目录和源码目录稳定不要用符号链接指向编译器头文件目录。5.3 编译器版本引起的隐性Bugarm-none-eabi-gcc不同版本对C语言标准的支持、对结构体内存对齐的处理、对-fno-common等选项的默认值都有差异。FMT官方通常在文档里给出推荐的编译器版本范围最好严格遵循。我踩过一个很典型的坑编译器从9.x升级到11.x后原来运行正常的FMT固件偶尔会随机死机排查了很久发现是新编译器针对某个未初始化变量的处理方式变了把一个结构体padding区域的随机值带进了浮点运算。后来对齐到官方指定版本问题消失。如果想用新版编译器至少要完整跑一遍FMT自带的单元测试和硬件在环测试不要直接上机试飞。编译器升级属于“出问题最难定位”的变更宁可保守。5.4 与RT-Thread软件包和第三方代码的依赖冲突FMT除了内核还会集成RT-Thread生态里的第三方包比如u8g2这样的显示驱动库。以在FMT上移植u8g2为例一般需要做三件事一是把u8g2源码放进一个目录二是写一个针对你板子I2C/SPI接口的移植文件提供底层发送字节的函数三是在SConscript里把u8g2源文件加入编译同时确保编译宏定义了U8G2_USE_XXX这类选项。注意RT-Thread软件包往往自带SConscript它依赖的宏开关可能和FMT自建的宏体系并不完全一致。如果发现杀毒软件或IDE的源文件索引把两份重名文件混在一起编译错误尤其奇怪。比如FMT的drv_i2c.c和RT-Thread软件包自带的drv_i2c.c同时被编译函数重定义。我的经验是第三方库尽量放在src/driver/third_party下并且修改它的SConscript确保只编译当前板级需要的文件不要全目录扫描。5.5 固件生成后无法启动的排查思路代码编译通过、固件也烧进去了但飞控没有任何反应。从构建角度来排查最先确认三件事一是rtconfig.h是否把支持该板级必备的设备都开启了比如系统时钟、Flash、调试串口二是链接脚本的Flash/RAM配置是否和芯片实际型号一致比如把Flash地址写错固件烧进去但CPU抓不到复位向量三是启动文件是否包含在构建列表里。FMT的启动文件通常用汇编编写放在board目录下如果SConscript漏掉了启动文件或者rtconfig.h里某个宏导致启动文件路径选择错误编译虽然通过但固件根本无法启动。这些问题的排查手段都差不多用调试器连接开发板查看PC寄存器停在哪里。如果能停在Reset_Handler说明Flash和CPU正常问题在C库初始化或RT-Thread启动早期如果PC停在0x0或随机地址大概率链接脚本或启动文件配置有误。6. 一些关于这套体系的实际体会做FMT开发这两年我最大的感受是这套“RT-Thread SCons”的组合表面上看是技术选型实际上是整个项目工程化思维的延伸。RT-Thread把实时任务的管理从“手写调度循环”提升到了“标准操作系统”的维度而SCons把“编译飞控固件”从“一堆脚本拼凑”提升到了“可编程、可组合、可扩展”的维度。刚开始可能会觉得SCons没有CMake那么普及、资料少但真在项目里跑一段时间之后会发现它在复杂嵌入式工程里的优势非常实在。如果你正在基于FMT做二次开发我建议先花一个下午把整个项目的SConscript树读一遍搞清楚每个模块从源文件到固件的路径再把RT-Thread的调度和设备管理机制过一遍之后再动手改代码会顺畅很多。FMT这套结构设计得比较清晰构建层的逻辑就是它的“骨架”骨架理解了后面所有功能开发都是在骨架里填充血肉。最后分享一个小技巧修改了rtconfig.h里的某个配置宏之后不要急着全量重新编译。先执行scons --menuconfig看看图形化配置下这个选项是否生效再执行scons -j4。因为有些宏是供脚本使用的SCons脚本本身会读取它们来决定源文件集合单纯改头文件里的宏可能不会触发SCons重新解析SConscript导致配置没真正生效。这个细节一旦踩到排查起来会很绕提前了解能省不少时间。