☰
Hello KRTS 最小工程:打通实时内核启动、任务调度与串口输出
2026/9/29 1:36:00 网站建设 项目流程

第一次接触 KRTS,我以为第一个项目会像学一门新语言那样简单:建个文件,写一行输出,敲个编译命令,终端跳出 Hello KRTS,收工。真上手才发现,这行字符串要穿过的关卡比想象中多得多——工具链要能对上目标架构,链接脚本要把代码放到正确的地址,复位向量要跳到正确的入口,主栈和任务栈要留足空间,最后还得有一条真正能被你看见的输出通道。上面任何一环没扣紧,你得到的都不是 Hello,而是一块安静得像砖头的板子,或者一串毫无意义的乱码。

这篇就围绕"Hello KRTS"这个最小项目展开,把它当作进入实时系统开发的第一道门槛来拆。我会讲清楚这个最小工程到底由哪些部分组成、每一部分为什么必须存在、构建链路怎么搭、跑通之后怎么确认它真的"实时",以及我在这个过程中踩过的那些不太会写进官方文档的坑。适合刚拿到 KRTS 内核源码或开发板、准备跑第一个程序的人,也适合从裸机开发转过来、对任务调度和内核启动流程还不太熟的朋友。

1. 一个 Hello 在实时系统里为什么值得单独写一篇

1.1 裸机点灯和内核打印,差的不是一点点

裸机开发的"第一个项目"通常是点灯,逻辑简单到极致:配时钟、配 GPIO、写寄存器、给延时循环,灯亮了就说明工具链和硬件没问题。整个过程是线性的,代码从头跑到尾,谁也不会打断你。而 KRTS 这类实时内核上的 Hello 完全不是同一回事。你的输出代码不是"主程序",它是被内核调度器管理的一个任务;在它真正被执行之前,内核要先完成一大堆自己的初始化:时钟、中断控制器、内存堆、调度器、空闲任务、系统节拍,少一个都不行。

换句话说,裸机的 Hello 是在验证"你的代码能不能跑",内核的 Hello 是在验证"内核能不能跑,并且能不能把你的代码当成一个任务正确地跑起来"。后者牵涉的环节数量大概是前者的三到五倍。这也是为什么我一直建议:不要跳过这个最小项目直接去做业务功能,因为你后面遇到的绝大多数诡异问题——任务不切换、打印卡死、系统莫名复位——本质上都能在这个最小工程里提前暴露出来。

1.2 跑通一个 Hello,等于打通了整条链路

KRTS 的最小可运行闭环可以拆成五环,缺一不可。

第一环是构建环:宿主机上的交叉编译工具链,能否把 C 源码编译成目标架构可执行的机器码。第二环是加载环:编译产物能否被正确地烧写或加载到目标设备/仿真环境中。第三环是启动环:芯片复位后从向量表跳到启动代码,再跳到内核入口,内核完成自身初始化。第四环是调度环:内核创建出你的任务,调度器在合适的时机把 CPU 交给它。第五环是观测环:任务执行的结果能通过某个通道(串口、仿真终端、日志缓冲区)被你看到。

提示:这五环里,任何一环断了,你看到的表象都是一样的——"没有任何输出"。所以排查时不要一上来就怀疑自己的代码,先按这五环从后往前一层层确认。

我把它们整理成一张对照表,方便你对照自己的环境逐项打勾:

环节关键依赖失败时的典型表象快速确认方式
构建环工具链前缀、目标架构、头文件路径编译报错、链接找不到符号看 map 文件是否生成、架构是否匹配
加载环烧写工具、地址配置、连接方式烧写成功但板子无反应读回校验、看加载日志
启动环向量表、链接脚本、启动汇编复位后立即挂死或反复重启单步到内核入口、看 PC 是否落在预期地址
调度环任务栈、优先级、节拍中断任务只跑一次或完全不跑在任务首行打断点或翻转 GPIO
观测环串口参数、终端配置、时钟无输出或乱码用已知波形/字符做对照测试

1.3 我用的环境和你的可能不一样,但思路通用

不同发行版、不同芯片的 KRTS 在细节上会有差异,比如初始化函数名字不同、BSP 目录结构不同、配置项开关不同。但上面那条五环链路是共通的,只要抓住它,换一块板子你也能在半小时内定位问题。

我这次用的环境是:Linux 宿主机 + 目标架构的交叉编译工具链 + 一套带 BSP 的 KRTS 源码树 + 一条串口线(或者仿真环境里的虚拟终端)。如果你用的是仿真器,观测环会更容易搭,因为虚拟终端不会出现时钟配置错误导致的乱码问题,调试启动流程时我更推荐先用仿真跑通,再上真板。

2. 动手之前,先把工程目录和构建链路理清楚

2.1 KRTS 源码树的分层,每一层都有自己的脾气

打开 KRTS 的源码根目录,你会看到一堆名字相近的文件夹,很多人第一次看会直接懵。我的习惯是先按"谁依赖谁"把它们分成三层来理解。

最底层是内核层,包含调度器、任务管理、内存管理、时钟节拍、中断框架这些与具体芯片无关的代码。这层基本不需要你动,你只需要知道它的入口在哪、初始化顺序是什么。中间层是硬件抽象层和 BSP,这部分和你的芯片强相关,包括启动汇编、链接脚本、时钟配置、串口驱动、中断向量表。第一个项目里 80% 的改动都发生在这一层。最上层是应用层,也就是你写 Hello 的地方,通常是一个单独的目录或文件,编译系统会自动把这个目录下的源文件加入构建。

krts/ ├── kernel/ # 内核核心:调度、任务、内存、时钟 ├── hal/ # 硬件抽象:与芯片族相关但可复用 ├── bsp/ │ └── your_board/ # 板级支持:启动代码、链接脚本、驱动 ├── components/ # 可选组件:日志、shell、文件系统 ├── include/ # 对外头文件 └── app/ # 你的业务代码,Hello 就写在这里

理解这个分层最大的好处是:当你改配置改崩了,你知道该回退哪一层。我见过有人把 BSP 的链接脚本改乱了,结果满世界找应用代码的 bug,白折腾两天。我的建议是,第一次跑通之前,BSP 和内核层的文件只做"必要且最小"的修改,并且每一次修改都记一笔,出了问题能快速回退。

2.2 工具链选型:版本对不上,后面全是玄学

工具链的问题是最容易被低估的。很多人以为只要编译不报错就行,其实工具链的小版本差异会直接影响生成代码的行为,尤其是涉及浮点、内存对齐、内联汇编的部分。我踩过最典型的一次是:编译器默认开启了某个优化,把内核里用于任务切换的上下文结构体做了重排,结果任务切换时寄存器保存错位,表现为系统跑几秒后随机挂死。这种问题看代码完全看不出来。

选工具链时我会确认三件事:一是前缀名与目标架构严格对应,别拿一个相近架构的工具链凑合;二是版本号与 KRTS 官方文档或 BSP 注释里推荐的一致,文档没写就去社区里找同款板子的成功案例;三是确认工具链自带的库版本,尤其是 C 库的裁剪情况。如果你的工具链是精简版的,部分标准库函数可能根本没有实现,这时候链接阶段会报找不到符号,别以为是内核的问题。

常见现象大概率原因处理方式
链接报 undefined reference工具链库裁剪、缺少对应实现换完整版工具链或用内核自带实现替换
编译通过但运行异常优化等级或 ABI 不匹配与 BSP 推荐配置对齐,先关优化验证
浮点运算结果错误硬浮点/软浮点配置不一致检查编译选项与链接选项是否统一
汇编文件报语法错汇编器方言不同确认工具链的汇编语法风格

2.3 编译配置里那几个一改错就全盘崩的项

KRTS 的配置文件通常集中在一个头文件或者一份 Kconfig 风格的菜单里。第一次配置时,你不需要把所有选项都搞懂,但有几项必须确认清楚。

第一项是目标芯片型号,这决定了 BSP 里哪套启动代码和链接脚本生效。第二项是系统节拍频率,它决定了任务调度的最小时间粒度,这个值不是随便填的,填太小会让中断开销暴涨,填太大则调度精度不够,我一般在第一个项目里用 1000Hz,也就是 1ms 一个节拍,兼顾精度和开销。第三项是主栈和各任务栈的大小,这是新手最容易设小的地方,栈溢出的表现往往是系统随机崩溃,极难定位。第四项是堆空间大小,如果你的 Hello 任务是用动态方式创建的,堆不够就会创建失败,而失败返回值经常被忽略。

/* 常见配置项示意,具体名字以你手上的版本为准 */ #define KRTS_TICK_RATE_HZ 1000 #define KRTS_MAIN_STACK_SIZE 2048 #define KRTS_HEAP_SIZE (16 * 1024) #define KRTS_MAX_PRIORITY 32

注意:改完配置一定要做一次全量重新编译,不要依赖增量编译。我就吃过这个亏,配置改了但部分目标文件没重新生成,跑的是新旧混合的代码,现象诡异到怀疑人生。

3. 让 KRTS 从复位向量一路走到那句问候

3.1 启动入口和内核初始化的正确顺序

芯片上电后,第一件事是硬件从固定的地址取出复位向量,跳到启动汇编。启动汇编做的事很固定:关中断、初始化栈指针、清 BSS、搬数据段、配置基础时钟,然后跳到 C 语言的入口。这个入口在不同 BSP 里可能叫SystemInit、board_init或者直接是main,你需要翻一下启动汇编的最后几行,看清楚它到底跳到了哪。

进入 C 世界之后,顺序非常关键:必须先初始化系统时钟和串口(否则后面什么都看不到),再初始化内核(建立任务管理和调度器),再创建任务,最后启动调度器。启动调度器这个函数一旦被调用,正常情况下就再也不会返回了,因为 CPU 从此交给调度器支配,永远在"找最高优先级就绪任务并切换过去"这个循环里转。

int main(void) { board_clock_init(); /* 时钟,必须最先 */ board_uart_init(); /* 输出通道,越早越好 */ krts_kernel_init(); /* 内核初始化:堆、任务表、调度器 */ create_hello_task(); /* 创建你的第一个任务 */ krts_scheduler_start(); /* 启动调度,正常情况下不返回 */ return 0; /* 能跑到这里说明调度器没启动成功 */ }

我在实际调试时,习惯在每一行后面加一句串口直接输出(绕过内核的日志组件),这样即使内核初始化卡住了,我也能知道卡在哪一步。这个"裸打印"技巧在启动阶段极其好用,很多人一开始就依赖内核日志,结果内核没起来,日志自然也没有。

3.2 输出通道:先让字符真实地出现在你眼前

Hello 的本质就是让字符出现在你眼前,所以输出通道必须先独立于内核验证通过。我的做法是在创建任务之前,先写一个最简单的阻塞式发送函数,直接用寄存器操作往串口数据寄存器里塞字符,然后循环发一串固定的字符串,比如"BOOT OK\r\n"。如果这串字符能正常显示,说明时钟、波特率、引脚复用、终端配置这一整条链路都是通的,后面内核打印出问题就只需要怀疑内核。

这一步骤值得花时间,因为串口是后面所有调试工作的基础。我总结过几个高频问题:波特率分频算错,导致输出全是乱码;引脚复用没配置,寄存器写得再对也没有波形出来;引脚的收发方向接反,表现为完全无输出;终端软件的波特率与代码不一致,同样是乱码。这里有一个非常实用的小技巧:如果乱码,先不要改代码,把终端软件里的波特率改成代码里设定值的一半或者两倍试试,如果乱码变得有规律了,说明就是分频计算的问题,方向就对了。

static void uart_putc_raw(char c) { while (!(UART_STATUS & TX_READY)) { } /* 等发送就绪 */ UART_DATA = (unsigned int)c; } static void uart_puts_raw(const char *s) { while (*s) { uart_putc_raw(*s++); } }

3.3 创建第一个任务:栈、优先级、入口函数

任务创建是 Hello 项目的核心动作。这里有几个参数必须想清楚,而不是随便填。

栈大小:任务栈要放得下局部变量、函数调用的返回地址、中断时的上下文保存。一个只做打印的任务,512 到 1024 字节通常够用,但如果里面调用了格式化打印函数,建议直接给到 2048,因为格式化函数内部会用到较大的临时缓冲。栈给小的代价是随机崩溃,给大的代价只是多占一点内存,在第一个项目里,我宁可先给宽裕一点。

优先级:第一个项目里其实只有一个任务,优先级填什么都行,但建议你养成为不同任务分层的习惯,比如把业务任务放在中优先级,日志、后台类任务放在较低优先级。

入口函数:任务函数通常是无限循环,因为任务一旦返回,内核的行为取决于是什么版本——有的直接删除任务,有的会触发断言。为了不引入不确定性,我习惯在任务末尾加一个死循环。

static void hello_task(void *param) { (void)param; uart_puts_raw("Hello KRTS!\r\n"); for (;;) { krts_task_delay(1000); /* 单位是节拍,1000 节拍 = 1 秒 */ } }

3.4 它到底实时不实时:一个五分钟能做的验证

很多人跑出 Hello 就收工了,我建议多花五分钟验证一下实时性,因为这才是你用 KRTS 而不是用裸机循环的意义所在。最简单的办法是在任务里翻转一个 GPIO,然后用逻辑分析仪或者示波器看波形周期。如果代码里写的是延时 1000 个节拍,而配置的节拍是 1000Hz,那波形周期应该是 2 秒(一个高电平加一个低电平,共 2 次延时)。测出来的周期如果明显偏大,说明节拍不准或者系统有额外开销;如果周期抖动很大,说明有更高优先级的中断在频繁打断。

没有仪器也能做,用内核提供的节拍计数函数在任务里采样,把连续两次唤醒时的计数值差打印出来,理想情况下差值应该稳定等于你设定的延时值。实测中允许有几个节拍的抖动,这在大多数应用里完全可接受。这一点我在后面第 5 章还会展开。

4. 从"没输出"到"输出乱码":我踩过的那些坑

4.1 上电完全没反应,我是这样一层层剥的

第一次烧写完成后板子毫无反应,是最让人焦虑的时刻。这时候最忌讳的是乱改代码。我固定按下面这个顺序排查,通常十几分钟就能定位。

第一层,确认代码真的被烧进去了。用烧写工具读回校验,或者读特定地址的字节看是否与编译产物一致。听起来很基础,但我确实遇到过烧写工具提示成功、实际只写了一部分的情况,原因是 Flash 保护位没解锁。

第二层,确认启动地址和链接脚本一致。链接脚本决定了代码被放在哪个地址,而芯片复位后会从某个固定地址取向量。如果链接脚本把向量表放到了 A 地址,芯片却从 B 地址取,那结果就是跑飞。打开编译生成的 map 文件,看向量表段和启动代码段是否落在预期地址。

第三层,确认时钟配置正确。时钟没配起来,芯片可能还在用内部低速时钟运行,串口分频对不上,自然没输出。这时候最有效的办法是在启动汇编配置完时钟后,立刻翻转一个 GPIO,用示波器测频率,能直接算出实际主频。

第四层,确认内核初始化没卡死。用前面说的裸打印,在每一行后面打一个字符,定位到具体卡住的函数。

第五层,确认调度器起来了。在任务函数的第一行打一个字符,如果前面都通了但这里没有,说明任务没被创建成功或者调度器没启动,回头检查任务栈是否够大、堆是否够用、创建函数的返回值是否被忽略。

4.2 乱码、丢字、首字符丢失:三个不同的病

输出出现了,但内容不对,症状需要细分,因为病因完全不同。

全是乱码,一般是波特率或时钟问题。判断方法前面说过:换速率猜测。如果换速率后乱码变成可读字符,就是分频问题;如果怎么换都乱,可能是引脚复用配置错了,信号根本没送到串口外设。

丢字符,通常是发送方式的问题。如果你用的是非阻塞发送,前一个字节还没发完就直接覆盖了数据寄存器,后面的字符就丢了。解决方式很简单,发送前等待发送完成标志,或者开启发送中断用队列发送。在第一个项目里,我强烈建议用最简单的阻塞式发送,虽然效率低,但结果确定。

首字符丢失,这个坑最隐蔽。串口外设上电后第一次发送时,有时候第一个字节会被硬件吞掉,原因通常是发送使能位刚置位,内部状态还没稳定。解决办法是在初始化完成后先发一个无关字符(比如换行或者空格)作为"热身",再发真正的内容。我第一次遇到这个问题时,对着代码看了两个小时,最后发现换行符没显示出来,内容整体往前挤了一格。

症状常见原因处理方式
全部乱码,换速率可读时钟或分频计算错误核对主频与分频寄存器计算过程
全部乱码,换速率无效引脚复用或方向配置错误检查引脚功能映射表
中间随机丢字符非阻塞发送覆盖寄存器改阻塞发送或加发送队列
首字符丢失发送使能后状态未稳定初始化后先发一个热身字符
打印到某处卡死栈溢出或格式化缓冲过大加大栈,拆分打印内容

4.3 编译通过、运行跑飞:栈和内存布局的隐形杀手

有一类问题最气人:编译链接全部通过,烧进去前几秒还能跑,然后随机挂掉或者直接复位。我遇到的原因十有八九是栈溢出或者内存布局冲突。

栈溢出的典型特征是症状随机、位置不固定,因为溢出的数据会覆盖相邻内存,覆盖到了什么就崩什么。排查方法有两种:一是把任务栈和主栈都开大一倍,如果问题消失,基本确认是栈的问题,再逐步缩小找到合适值;二是给栈填充一个固定魔数,在运行一段时间后检查栈底的魔数是否被改写,这能直接算出栈的实际使用峰值。这个方法我在后面 5.1 节给了具体做法。

内存布局冲突通常发生在你自己调整了链接脚本之后,比如把堆和栈放在了重叠的地址范围,或者把某个段放到了不存在的存储区域。编译链接阶段不会报错,因为链接器只关心符号地址,不关心你的硬件有没有那块内存。这类问题只能靠仔细核对数据手册的内存映射表来解决。

还有一种隐蔽情况:中断向量表位置被你的应用代码覆盖。比如你的代码段起始地址和向量表重叠,程序一运行就把向量表改写了,第一次中断来的时候就跑飞。检查方法还是看 map 文件,确认各个段的地址区间没有交叠。

5. 把这个最小工程变成能长期用的模板

5.1 给最小工程装上面板:日志分级和栈水位检测

Hello 跑通只是起点,接下来我建议立刻做两件事,它们会在后面帮你省下大量时间。

第一件是日志分级。不要到处直接用裸打印,而是把输出封装成分级日志接口,至少有调试、信息、警告、错误四个级别,并且支持运行时过滤。这样你在业务代码里可以放心地加日志,需要的时候再打开对应级别,不会因为日志太多拖慢系统。封装的时候注意一点:日志函数的输出最好走一个独立的低优先级任务,用队列传递,避免在中断或者关键任务里做长时间的阻塞发送。

#define LOG_LEVEL_DEBUG 0 #define LOG_LEVEL_INFO 1 #define LOG_LEVEL_WARN 2 #define LOG_LEVEL_ERROR 3 void log_write(int level, const char *tag, const char *fmt, ...);

第二件是栈水位检测。实现方式很土但极其有效:创建任务前,把整个任务栈区域填成0xA5A5A5A5这个魔数,任务运行一段时间后,从栈底往上看,找到第一处不是魔数的位置,就能算出栈实际用到了多少。把这个值打印出来,你就知道自己的栈设置是紧巴巴还是过于宽松。我在项目里会把这项检测做成一个低频的监控任务,运行一段时间后统一输出。

提示:魔数的选择有讲究,别用 0x00 或者 0xFF,因为这两种值在正常运行中也可能出现,容易被误判。0xA5A5A5A5 这类不太可能在正常数据中出现的模式更可靠。

5.2 加一个周期任务,把节拍和调度行为看清楚

第二个任务建议加一个周期任务,专门用来观察调度行为。让它以固定的节拍延时循环,每次唤醒时记录当前节拍计数和实际唤醒时刻,算出差值并统计最大值和平均值。理想情况下,平均值应该等于你设定的延时值,最大值反映的是系统在最坏情况下的调度延迟,这个指标对实时系统来说才是真正重要的。

如果最大值明显偏大,可以从三个方向查:一是有更高优先级的中断处理时间过长,导致调度被推迟;二是有任务关闭了中断或者进入了临界区,时间太长;三是节拍中断的优先级设置不合理,被其他中断压住了。我遇到过一次最大值忽高忽低,最后发现是某个驱动在中断里做了几十微秒的忙等待,把它改成状态机之后,抖动立刻降下来了。

static void monitor_task(void *param) { unsigned int last = krts_tick_get(); unsigned int worst = 0; for (;;) { krts_task_delay(100); /* 100 节拍 */ unsigned int now = krts_tick_get(); unsigned int delta = now - last - 100; if (delta > worst) { worst = delta; } last = now; if (worst > 3) { log_write(LOG_LEVEL_WARN, "mon", "jitter worst=%u", worst); } } }

5.3 版本归档、命名规范和下一步往哪走

第一个项目跑通之后,把它归档成一个模板工程,后面的所有实验都从这个模板复制出来。归档时我会做三件事:把这次为了跑通做过的所有改动写进一份简短的说明文件,包括改了哪个配置、为什么改、改之前是什么值;把工具链版本、内核版本、板子型号记录下来;把验证通过的最小代码提交到版本控制,打一个标签。

命名规范也建议从第一个项目就定下来:任务函数统一用_task后缀,初始化函数统一用_init后缀,日志标签用不超过四个字符的缩写。这些看似琐碎,但当你的项目里出现二十个任务的时候,清晰的命名能让你少翻很多次代码。

至于下一步,我一般会按这个顺序推进:先把串口接收也打通,做一个简单的命令行外壳,能通过终端输入命令;然后加进去一个定时器和一个事件通知机制,体会一下任务间通信的基本用法;再往后是信号量、互斥量这些同步原语。每一个都在这个模板上做加法,出问题随时可以回到干净的 Hello 版本对照。

最后分享一点我在实际项目里的体会:真正让人头疼的从来不是业务逻辑,而是那些"看起来已经跑通"的基础环节。我见过太多项目在中期突然出现随机崩溃,回头一查,根源就是第一个项目时留下的一个没验证的输出通道、一个设小的栈、或者一个被忽略的函数返回值。把 Hello 这一个项目做扎实,把每一环都亲自验证一遍,比急着往上堆功能要划算得多。

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

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

立即咨询