☰
RT-Thread构建与配置系统全解析:SCons、Kconfig与menuconfig实战
2026/9/29 9:30:28 网站建设 项目流程

做了这么多年嵌入式,接触过各种RTOS和工程构建方式,RT-Thread的构建与配置系统是我用过最顺手的一套组合。它把SCons、Kconfig、menuconfig和env工具串成了一条完整链路,编译、裁剪、扩展组件基本可以做到一条命令完成,不用像以前那样手改Makefile或在一个个IDE工程里来回勾选。

这篇文章把RT-Thread的构建与配置系统从原理到实操都拆开讲一遍,适合刚入门RT-Thread、想把项目从STM32裸机迁移到RTOS的开发者,也适合想搞明白“scons到底在干什么”的进阶用户。我会先讲整体的设计思路,再拆解menuconfig和rtconfig.h的关系、SConscript脚本的写法,最后给一个完整的驱动模块添加流程和排错方案,看完你就能动手改造自己的板子。

1. RT-Thread的构建与配置系统全景

1.1 你要会的那三板斧:env、scons、menuconfig

如果你用过RT-Thread,一定听过“用env工具编译工程”这个说法。env不是编译器,不是一个IDE,它是一个集成环境外壳,帮我们临时配置好Python、SCons、编译工具链这些环境变量,然后在当前目录下执行scons或者scons -j8就能把固件编译出来。你还可以通过scons --target=mdk5直接生成Keil MDK5工程,生成完用Keil打开继续开发调试。这套流程用顺手之后非常舒服。

整个系统的角色分配是这样的:

  • SCons:真正的构建引擎,类似Makefile里的make,但它用Python脚本描述构建过程,跨平台、可编程能力极强。
  • Kconfig/menuconfig:负责“配置裁剪”,让你通过一个树状菜单勾选要哪些功能、不要哪些功能。
  • SConscript:以脚本文件形式定义每个组件/目录怎么参与编译,负责把源文件、头文件路径、编译选项交给很多人。
  • rtconfig.h:menuconfig配置后生成的头文件,是源码里判断功能是否开启的唯一依据。整个系统的逻辑就用#ifdef RT_USING_XXX这样的宏来展开或关闭代码。
  • env工具:把以上命令聚合起来,还提供menuconfig命令的调用入口和pkgs包管理器的支持。

看到这里你应该明白,RT-Thread的构建与配置系统不是一个单一的编译脚本,而是一套覆盖“配置→生成→依赖解析→编译→导出工程”全流程的工具链组合。单独看scons你会莫名其妙,单独用menuconfig你也感受不到它的威力,只有把它们串起来理解,你才算真正入了门。

1.2 为什么RT-Thread选SCons而不是纯Makefile

说到嵌入式构建,大家第一时间想到的是Makefile,或者Keil/IAR里的自动编译。RT-Thread没有走这条路,而是选了SCons,我刚开始也觉得奇怪,踩过几个坑之后才理解这个选择有多聪明。

Makefile的老问题是跨平台能力弱,Windows/Linux/macOS上的写法有差异,递归make的时候依赖关系经常出问题。而且嵌入式工程有大量条件判断:某颗芯片要不要这个驱动、某个组件依赖另一个组件、头文件搜索路径要随配置动态变化。写Makefile的人都知道,这些逻辑一旦复杂起来,就变成了“能跑就行,但没人敢改”的状态。

SCons本质上是用Python写构建规则,天然具备编程能力,跨平台运行也做得很好。RT-Thread在SCons之上又封装了一层,让我们只需要写一个SConscript文件,里面用DefineGroup声明一个组件,然后用Glob('*.c')自动收集当前目录的源文件,再指定头文件路径和依赖项,剩下的交给框架去处理。这个设计让“新增一个驱动目录”的成本变得很低,这也是RT-Thread能做成组件化生态的一个重要基础。

有人会问,那我直接用Keil工程不是更方便吗?确实,很多新手还是习惯打开Keil直接Add Group、Add Files。但问题是,一旦你的应用代码量大、外设驱动多、软件包依赖深,手动维护工程几乎不可能。RT-Thread的处理方式是:你先用scons管理源码层面的构建,最后再通过scons --target=mdk5导出工程给IDE使用,配置文件和源文件的增删都由脚本自动完成。你只在IDE里做编译调试,工程生成的脏活累活全交给脚本。

2. 配置系统:Kconfig和menuconfig是怎么工作的

2.1 menuconfig从启动到生成rtconfig.h的完整流程

很多人第一次用RT-Thread都是在BSP目录下执行menuconfig,看到蓝色界面,一堆选项,以为这就是个配置工具,其实背后发生的事情比你想象的多。

menuconfig命令本身来自Linux内核社区那套Kconfig配置系统,RT-Thread把它移植了过来。你在源码树的顶层(通常是BSP目录下,也就是rtconfig.h所在的目录)执行menuconfig,它会读取根目录下的Kconfig文件,这个Kconfig文件是一棵逻辑树的开端,它通过source指令把各个子模块的Kconfig文件引入进来,层层展开成菜单。

接下来你在界面里做的每一次切换,本质上是修改一个叫.config的文件。这个文件保存了当前所有配置项的值,比如:

CONFIG_RT_USING_XXX=y CONFIG_XXX_ENABLE=1

当你选择“保存并退出”,环境工具会调用内部脚本,把.config和rtconfig.h做一次同步。同步的结果就是生成一个全新的rtconfig.h头文件,里面全是#define RT_USING_XXX这样的宏定义。此后,源码里所有#ifdef RT_USING_XXX的地方才开始真正生效。

这里有个关键点:menuconfig本身不会编译任何东西,它只负责产出配置文件和头文件。真正吃这些配置的是SConscript和代码本身。所以在menuconfig改完配置之后,一定要重新运行scons -j8才会生效,而且建议在重新编译前先执行scons -c清理一次,防止旧的编译产物导致一些莫名其妙的链接错误。

我自己的做法是:在BSP目录下执行menuconfig,改完保存退出后,直接执行scons -j8,如果只是调整宏配置,基本都能正确增量编译。但如果我改了文件目录结构、增删了源文件,我会先scons -c清理,再全量编译一把,避免残留的.o文件“骗”过构建系统。

2.2 Kconfig语法与配置项如何被源码消费

理解Kconfig语法不需要学得很深,你会看几个关键词就够了。一个最小的配置项长这样:

menu "Demo Device Driver" config RT_USING_DEMO bool "Enable Demo Device" default y help Say Y here to enable demo device. endmenu

这个配置项在menuconfig里会显示成一个开关选项,名为“Enable Demo Device”。它对应的环境变量是CONFIG_RT_USING_DEMO,最终生成到rtconfig.h里就是:

#define RT_USING_DEMO 1

默认值是y,表示默认开启。你还可以用depends on表达依赖关系,比如:

config RT_USING_DEMO bool "Enable Demo Device" depends on RT_USING_SERIAL default y

意思是只有打开了串口,这个Demo设备才可选择。这种依赖关系在复杂系统里非常有用,它能够在配置阶段就避免你选择“不可能存在”的组合,省去很多编译期的报错。

那rtconfig.h里的宏是怎么被消费的?有两个方向。第一个方向是给源代码用的,比如你的驱动文件里:

#ifdef RT_USING_DEMO static int demo_device_init(void) { // 注册设备相关代码 } INIT_BOARD_EXPORT(demo_device_init); #endif

当宏没定义时,这段代码直接不会参与编译。第二个方向是给SConscript用的,后续会讲到,SConscript可以通过GetDepend(['RT_USING_DEMO'])判断这个宏是否打开,从而决定要不要把当前目录下的源文件加入编译列表。

所以整个逻辑链就是:Kconfig定义选项→menuconfig修改选项→生成rtconfig.h宏定义→源码和SConscript按宏定义决定编不编译。这一环扣一环的链路,就是整套配置系统运行的核心机制。

3. SConscript脚本:RT-Thread组件化的底座

3.1 group:把源文件打包成可复用的组件

在RT-Thread的源码树里,几乎每个组件目录都放着一个SConscript文件。这个文件的核心工作就是调用DefineGroup,把一个目录下的源文件、头文件、定义和依赖打包成一个逻辑组件。

一个最常见的SConscript模板是这样:

import os from building import * cwd = GetCurrentDir() src = Glob('*.c') CPPPATH = [cwd] group = DefineGroup('demo_driver', src, depend = ['RT_USING_DEMO'], CPPPATH = CPPPATH) Return('group')

这里逐行解释一下:

  • GetCurrentDir()返回当前脚本所在的目录路径,这个路径后面会被添加到头文件搜索路径里。
  • Glob('*.c')自动匹配当前目录下所有.c文件,生成源文件列表。如果你有子目录需要递归,可以用Glob('*.c') + Glob('subdir/*.c'),或者直接用Glob('**/*.c')匹配所有子目录。
  • CPPPATH = [cwd]意思是把当前目录作为头文件搜索路径。
  • DefineGroup的第一个参数是组件名称,第二个是源文件列表,第四个参数是关键,表示这个组件依赖RT_USING_DEMO这个宏,只有在rtconfig.h里打开这个宏,源文件才会被真正加入编译。

这种写法把“哪些文件要编”和“这个组件依赖什么功能”维护在同一个文件里,逻辑非常直观。我自己新增驱动模块的时候,都是直接复制一个现成的SConscript,改一下路径和依赖宏,几分钟就能接入构建系统。

3.2 条件编译与依赖管理:depend的本质

DefineGroup里的depend参数,本质上是在帮你管理编译列表。RT-Thread的构建框架在收集所有组件后,会根据rtconfig.h里实际的宏定义,过滤掉那些依赖未开启的组件。例如你定义了一个组件依赖RT_USING_DEMO,但配置系统里没有打开这个宏,那么整个组件的源文件都不会出现在编译列表里,头文件搜索路径也不会加入工程。

这个过滤机制的实现原理其实不复杂,SCons构建框架会维护一个所有组件的大列表,然后通过GetDepend函数获取配置项当前的值,最终决定组件的去留。不过我们大多数时候不需要深究内部实现,只要知道:一个源文件能参与到编译中,需要同时满足两个条件,一是它的SConscript被正常执行并返回了group,二是它的depend依赖在rtconfig.h中已定义。

如果你在添加自己代码的时候遇到“明明文件在,但编译没包含”的问题,十有八九是depend里写的宏没打开。排查办法很简单:打开rtconfig.h,搜索对应的宏名,确认它是否存在。如果不存在,就去menuconfig里找到对应配置项打开,再重新生成头文件。

3.3 SConscript的目录层级递归机制

RT-Thread的工程树很深,从BSP目录往下是Libraries、drivers、applications,再往上是components、examples等。这么多目录,SCons是怎么知道每个目录都要执行里面的SConscript的?

关键在于各个层级都会有一个顶层的SConscript文件,通过objs = objs + SConscript('子目录/SConscript')这种形式,把子目录的构建对象串联起来。你可以在任意一个BSP目录下的顶层SConscript里看到类似结构:

objs = [] objs = objs + SConscript('../../Libraries/HAL_Drivers/SConscript') objs = objs + SConscript('drivers/SConscript') objs = objs + SConscript('applications/SConscript')

这样一层一层递归下去,最终把整个工程里所有需要编译的目录都汇集到一棵“对象树”里,SCons再根据这棵树生成最终编译和链接规则。这个机制的好处是组件边界非常清晰,你完全可以在不影响其他组件的情况下,新增一个独立目录并在顶层SConscript里加上一行,就能集成进整个工程。

4. 一次完整的构建流程拆解

4.1 工具链与rtconfig.py:交叉编译如何生效

在真正执行scons之前,你要先确认工具链配置正确。RT-Thread通过rtconfig.py文件来声明编译器路径、编译器前缀和编译选项。这个文件通常放在BSP目录下,由env工具自动生成,也可以手动修改。

以常见的ARM GCC为例,rtconfig.py里会有一段类似这样的内容:

import os if os.getenv('RTT_CC'): CROSS_TOOL = os.getenv('RTT_CC') else: CROSS_TOOL = 'gcc' if os.getenv('RTT_ROOT'): RTT_ROOT = os.getenv('RTT_ROOT') else: RTT_ROOT = r'D:\RT-ThreadStudio\repo\rt-thread' PLATFORM = 'gcc' EXEC_PATH = r'D:\RT-ThreadStudio\tools\gnu_gcc\arm_gcc\mingw\bin' PREFIX = 'arm-none-eabi-'
  • EXEC_PATH告诉你编译器可执行文件在哪。
  • PREFIX是交叉编译工具链的前缀,比如arm-none-eabi-gcc里的arm-none-eabi-。
  • RTT_ROOT指向RT-Thread源码根目录,env工具会通过环境变量设置它,手动搭建环境时经常在这里出错。

如果你用env工具,它会自动把EXEC_PATH和RTT_ROOT配置好,不需要手动改。如果你在Linux服务器上做CI编译,那多半需要手动修改这份文件的路径和前缀,指向你服务器上安装的交叉编译工具链。改完记得用scons --verbose验证一下实际调用的编译器路径是否正确。

4.2 scons -j8到固件输出:一次实际编译的完整命令链

当你执行scons -j8,RT-Thread的构建系统做的事情远不止“把C文件编译成.o再链接”这么简单。整个过程大致可以分为这几步:

第一步,SCons读取顶层SConscript,然后递归执行所有子目录的SConscript,收集全部源文件、头文件路径、宏定义和库路径。这个过程非常快,因为是纯Python解析,没有调用外部编译器。

第二步,SCons根据收集到的信息生成编译任务,核心是生成大量的.o文件。每条编译命令大概长这样:

arm-none-eabi-gcc -c -mcpu=cortex-m7 -mthumb -mfpu=fpv5-d16 -mfloat-abi=hard -DSTM32H743xx -DRT_USING_H743 -I. -Iapplications -Idrivers -I.Libraries/HAL_Drivers -IC:/RT-Thread/repo/include ...

这里面能看到芯片型号的宏定义、头文件搜索路径、优化选项等,全部是根据你的配置和SConscript自动组合出来的。

第三步,所有目标文件收集完成后,SCons生成链接命令,调用arm-none-eabi-gcc的链接器把所有.o文件、静态库一起链接生成最终固件rtthread.elf,然后再调用arm-none-eabi-objcopy生成rtthread.bin和rtthread.hex,方便烧录。

想要看到每一条真实编译命令,执行scons --verbose即可。遇到编译报错时,我通常都是先跑一次--verbose,把实际的命令提出来手动执行一遍,这样能快速区分是命令行问题、头文件路径问题还是源码本身的问题。

4.3 工程导出:scons如何生成Keil/IAR工程

RT-Thread有一个非常实用的能力:导出IDE工程。你只需要在配置完成、源码就绪后执行:

scons --target=mdk5

就能在当前目录下生成.uvprojx工程文件,直接用Keil打开编译。同理,scons --target=iar生成IAR工程,scons --target=vs生成Visual Studio工程。由于RT-Thread的构建系统已经有完整的源文件、头文件目录和宏定义信息,它生成的目标IDE工程是完全自洽的,不需要你在IDE里手工调整任何东西。

这个导出功能的底层机制并不神秘:SCons的project生成器会把收集到的信息转换成IDE工程文件的XML格式。但它在实际开发中有很强的实用价值。我的习惯是在Linux服务器上维护一份完整的源码和配置,导出工程到本地Windows用Keil调试,两边共用一份源码,不用同步两份工程文件。如果你对Keil比较熟,完全可以把它当成RT-Thread的“前端”,而SCons是“后端”,后端管逻辑,前端管调试。

5. 手把手:从零添加一个自己的硬件驱动模块

5.1 规划目录与依赖关系

理论讲完,直接上手一个最典型的场景:给一块新板子添加一个自定义驱动模块。假设这个驱动的名字叫demo_sensor,功能大概是从某个I2C传感器读到数据,然后通过RT-Thread的设备框架注册成标准设备。

第一步是规划目录。在BSP目录下的drivers子目录里新建一个demo_sensor文件夹,里面放四个文件:

drivers/demo_sensor/ ├── Kconfig ├── SConscript ├── demo_sensor.c └── demo_sensor.h

依赖关系也要先想清楚:这个驱动基于I2C总线,所以要在配置层面保证RT_USING_I2C是打开的;设备注册依赖RT-Thread的设备框架,所以要依赖RT_USING_DEVICE。随后,在Kconfig里把这两个依赖写清楚,menuconfig会自动帮你做校验,如果没开I2C,这个驱动选项会直接置灰。

5.2 编写Kconfig和SConscript

然后写Kconfig,放在drivers/demo_sensor/Kconfig里:

menuconfig RT_USING_DEMO_SENSOR bool "Enable Demo Sensor Driver" default n depends on RT_USING_I2C if RT_USING_DEMO_SENSOR config DEMO_SENSOR_I2C_BUS string "I2C bus name" default "i2c1" endif

菜单让人能选择是否启用这个驱动,还预留一个配置项来填写I2C总线的名字。default n表示默认关闭,防止一开始引入没做好的代码就把工程带崩。

编写SConscript:

import os from building import * cwd = GetCurrentDir() src = Glob('*.c') CPPPATH = [cwd, str(Dir('.'))] group = DefineGroup('DemoSensor', src, depend = ['RT_USING_DEMO_SENSOR'], CPPPATH = CPPPATH) Return('group')

这里有个地方值得说明:depend = ['RT_USING_DEMO_SENSOR']这个依赖项必须和Kconfig里的宏名完全一致,否则会出现“你在menuconfig里开了,但SConscript还是不编这个驱动”的问题。拼写不一致是这类问题里最高频的坑,没有之一。

最后,把新的子目录挂进上层SConscript。在drivers目录的SConscript里加上:

objs = objs + SConscript('demo_sensor/SConscript')

这一步千万别漏,漏了之后整个demo_sensor目录根本不会被执行到,源码也就不会被收集进工程。

5.3 menuconfig启用并编译验证

完成规划和脚本编写后,回到BSP目录下执行:

menuconfig

在菜单中进入Drivers → 找到“Enable Demo Sensor Driver”,按空格勾选,如果I2C没开它会显示灰色,要先回到I2C选项打开。保存退出,然后执行:

scons -c scons -j8

编译时重点观察终端输出里是否有.o文件和.d文件的生成,以及链接阶段是否有符号缺失。编完之后强烈建议再导一次工程:

scons --target=mdk5

打开Keil确认demo_sensor目录确实出现在工程树里,以及rtconfig.h中自动生成了RT_USING_DEMO_SENSOR宏。这一步能确认配置系统、构建脚本和IDE工程三方完全同步。我早年踩过一次坑,只改menuconfig没重新导出工程,结果Keil里编译的还是一份旧代码,排查了一个多小时才发现是工程文件没刷新。

6. 高频问题与排查技巧实录

6.1 环境与工具链问题

问题1:cmd里找不到scons命令

这是最常见的环境问题。env工具的本质是帮你设置环境变量,如果你在普通的命令行终端执行scons,大概率会提示“不是内部或外部命令”。正确做法是在env工具打开的终端里操作,或者手动把env工具目录下的Python和Scripts目录加入系统环境变量PATH。如果不想每次开env,也可以手动配置好Python、SCons、编译工具链的PATH,就能在自己的终端里编译了。

问题2:编译时出现cc1.exe无法执行

这个报错通常意味着EXEC_PATH配错了,或者交叉编译器和你当前系统架构不匹配。比如在Windows下用了Linux版本的工具链,肯定跑不起来。解决方案是打开rtconfig.py,检查EXEC_PATH是否指向真实的编译器目录,PREFIX是否正确,然后重新打开终端让环境变量生效。

6.2 配置与编译问题

问题3:menuconfig保存后,rtconfig.h没有更新

正常情况下保存退出后rtconfig.h会同步更新,但偶尔也会出现没刷新。先检查终端里是否有报错信息,常见原因是路径中包含中文字符或空格,导致Kconfig生成脚本无法写入。解决方法是把整个工程放到纯英文、无空格的目录下。另外,如果你手动改过rtconfig.h,menuconfig会提示确认覆盖,选择同意后才能同步。

问题4:源码里明明写了#ifdef RT_USING_DEMO,却总是编译不进去

首先确认rtconfig.h里真的生成了这个宏,其次确认这个宏出现在正确的位置,不要在Kconfig里定义成RT_USING_DEMO,但代码里用的是RT_USING_DEMO_SENSOR,对不上肯定不行。如果宏都对,再来查SConscript的depend参数,它同样决定了这个模块要不要编译。这两个位置必须同时满足条件。

6.3 构建脚本踩坑

问题5:新增源文件后,编译没生效

在SCons里,Glob('*.c')是在解析SConscript时立即匹配的。如果你在编译执行之后才往目录里丢文件,建议先执行scons -c清理,再重新编译。SCons的依赖系统虽然会检查文件变化,但很多情况下源文件列表的刷新不如全量清理来得干净,尤其是你把源文件从一个目录移动到另一个目录时。遇到这种情况,不要犹豫,清理重编永远是最快的解法。

问题6:链接时提示重复定义

大多数情况是同一个源文件被多个SConscript同时收集到了。比如你在驱动目录的SConscript里用Glob('*.c'),又在它父目录的SConscript里用递归匹配包含了一遍。排查时打开--verbose查看编译列表,找出重复的.o文件,然后把多余的一组收集方式去掉即可。另外,如果目录下有xxx.bak.c这样的文件被Glob匹配进去了,也会出现重复定义。所以建议不要用Glob('*.c')全量收集的目录放置任何非参与编译的c后缀文件,临时文件用.txt后缀保存。

6.4 常见问题速查表

问题描述可能原因排查顺序
scons不是内部或外部命令环境变量未设置确认在env工具终端运行
cc1.exe无法执行编译工具链路径或版本错误检查rtconfig.py里的EXEC_PATH和PREFIX
编译不包含新增源文件Glob匹配未刷新或SConscript未挂载scons -c后重新编译,检查父目录SConscript
链接时重复定义源文件被多个SConscript收集--verbose查看.o列表,去除重复收集
menuconfig后rtconfig.h无变化路径含中文/空格,或手动修改过清理目录路径切换到纯英文目录
depend依赖宏没定义Kconfig和SConscript拼写不一致对比rtconfig.h与SConscript中宏名
链接时找不到某函数定义该模块依赖未打开或源码未包含用GetDepend检查依赖,确认源文件列表
Keil工程里找不到新增目录导出的工程文件未刷新重新执行scons --target=mdk5
编译报错fatal error: rtdef.h No such fileCPPPATH未包含RT-Thread核心头文件目录检查SConscript里的CPPPATH,或RTT_ROOT是否配置

这套速查表是我在实际开发里一分一分整理出来的,碰到问题先对着表过一遍,多数情况不用深挖就能定位。RT-Thread的构建与配置系统是很成熟的工具组合,出错大多不是系统本身的问题,而是环境、路径、宏名这类“人祸”。

顺带分享一个小技巧,我习惯在项目根目录写一个构建脚本,里面先做环境检查,再执行scons -c和scons -j8,最后自动跑--target=mdk5导出工程。这样一条命令处理所有事,既方便本地开发,也方便丢到CI服务器上做自动构建。把构建过程从“手工操作”变成“自动化工具”之后,整个项目的迭代效率会有非常明显的提升。

RT-Thread这套构建与配置体系初学会觉得命令多、文件杂,但只要把Kconfig、SConscript、rtconfig.h这三个文件的关系理清楚,用起来会非常顺手。我第一次用scons导出Keil工程的时候还挺惊讶的,原来嵌入式工程的构建自动化可以做到这个程度。后来维护的项目多了,凡是涉及多芯片、多板卡、多个软件包的场景,我基本都会优先考虑用RT-Thread的方案,因为它把“这个平台要不要编这个文件”这种琐碎问题,彻底变成了一套可声明、可继承、可复用的规则。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询