ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ohos liteos_m内核启动链路全解析:从复位向量到第一个任务

ohos liteos_m内核启动链路全解析:从复位向量到第一个任务

第一次接触 ohos liteos_m 内核启动时,我犯过一个所有嵌入式初学者都会犯的错:以为内核启动就是把调试器连上、烧录、跑一个点灯 Demo,然后看到 LED 亮就大功告成。直到后来我把 LiteOS-M 往一块只有 64KB Flash 的 MCU 上移植,点灯死活不亮,连调试器的初始断点都停在了一个莫名奇妙的汇编地址,我才老老实实回到 reset 向量,从第一条指令开始逐行读启动代码。这之后我才真正理解,启动流程不是一个"跳过也没关系"的例行公事,而是整个内核能否正确运行的根基。

这篇文章我会完整拆解 ohos liteos_m 内核启动链路:从上电复位后的向量表、链接脚本、启动汇编,到 C 环境初始化、时钟与内存就绪,再到调度器点火、第一个任务上 CPU,最后加上我实际移植和排错中遇到过的几个典型坑。内容既适合刚想入门 OpenHarmony 设备开发的读者,也适合已经在跑 LiteOS-M 但还没认真研究过启动细节的朋友。读完之后,你再碰到"板子起不来""调度没反应""进不了 main"这类问题,应该能有一个清晰的排查方向,而不是全靠猜。

1. 为什么 liteos_m 的启动流程值得单独拎出来拆解

1.1 不搞清楚启动链路,后面所有调试都是盲人摸象

很多嵌入式开发者在写裸机程序时对启动流程并不陌生:复位、初始化栈、清 BSS、跳 main。但内核场景比裸机复杂得多,因为从第一条指令到你的业务任务开始运行,中间穿插着一堆我们平时根本看不见的"基础设施搭建":向量表重定位、系统堆初始化、tick 时钟配置、调度器数据结构准备、第一个任务的上下文切换。

如果这套流程中任何一环出了问题,表现往往非常隐蔽。我见过最典型的例子是:代码编译通过、烧录成功,但程序跑起来后要么进不了 main,要么进了 main 却卡死在某个while循环里,要么内核初始化"成功"了,调度器却永远切不到业务任务上。这些问题不回到启动链路去查,光在业务代码层面找一辈子也找不到。

LiteOS-M 作为 OpenHarmony 面向 MCU 场景的轻量内核,它的启动流程并不算复杂,但每一步都有明确的依赖关系。比如内存池没有初始化完成,就不能创建任务;SysTick 没有启动,调度器的时间片和延时机制就是空的;SVC/PendSV 的优先级配置不对,任务切换就会在中断上下文里出问题。这些依赖关系只有一次性把启动链路通读一遍才能建立直觉。

1.2 这篇文章能帮你获得什么

这不是一篇泛泛的架构科普,而是能跟着源码路径走的实战拆解。我会把从reset_vector到LOS_StartToRun的完整调用链梳理清楚,同时把每个环节里"为什么要这样做"讲明白。

比如:

  • 链接脚本里那个放在 0 地址的向量表,为什么必须是栈顶地址开头?
  • 汇编启动代码为什么要在进入 C 世界之前关中断?
  • LOS_KernelInit到底初始化了哪些东西,顺序能不能换?
  • LOS_StartToRun之后为什么就"回不去"了?
  • 调度器启动的那一刻,CPU 是怎么从跑启动代码切换到跑第一个任务的?

这些问题搞懂之后,你再回头看 LiteOS-M 的启动过程,就不是一串模糊的函数名,而是一条有逻辑、有因果、可以推理的链条。即便你将来不是用 LiteOS-M,而是换到其他 RTOS,这套排查思路同样适用。

2. 复位那一刻:向量表、链接脚本与进入 C 世界的桥

2.1 向量表不是给你看的数据,是 CPU 的"提线木偶线"

在 Cortex-M 架构上,CPU 上电复位后并不是从什么main函数开始执行的,而是由硬件直接从内存地址0x00000000读取初始栈指针(MSP),再从地址0x00000004读取复位向量,也就是第一条要执行的指令地址。这个过程没有任何软件参与,纯硬件行为。

所以内核能否启动,第一决定因素不是代码逻辑,而是中断向量表是否正确躺在 Flash 起始位置。LiteOS-M 的向量表结构非常常规:偏移 0 放栈顶地址,偏移 4 放复位入口函数地址,后面依次是 NMI、HardFault、各类外设中断入口。

我读启动代码时有个特别深的感触:嵌入式开发里的"约定"往往是硬性的。CPU 不看你的变量名、不看函数名,就看向量表第 0 个词是不是一个合法 RAM 地址,第 1 个词是不是一个合法的代码地址。如果链接脚本把向量表放错位置,哪怕你的 C 代码写得再正确,CPU 复位后的第一步就是跑飞。

2.2 链接脚本里的三个关键符号:栈顶、堆顶和入口地址

既然 CPU 复位后直接从向量表拿栈顶和复位入口,那么链接脚本就必须保证两件事:第一,向量表段被放置在目标 MCU 的起始 Flash 地址;第二,代表栈顶的符号、代表堆起始的符号,都要在链接阶段正确计算出来。

在 LiteOS-M 工程里,链接脚本(.ld 文件)里通常会有几个非常关键的内存布局定义:

  • g_top_of_stack:栈顶地址,一般指向 RAM 段的最高地址。Cortex-M 的栈是向下生长的,所以初始栈指针必须是 RAM 的最高地址加 1。
  • g_sysMemStart和g_sysMemSize:系统堆的起始地址与大小。LiteOS-M 在系统堆上实现动态内存分配,任务栈、内核对象都从这块内存池里分配。
  • __bss_start、__bss_end:bss 段边界,启动汇编靠这两个符号清零未初始化全局变量。

我最早自己搭工程时犯过一个典型的错:栈顶符号定义对了,但 RAM 段里还放了一个比较大的全局缓冲,导致栈顶被"顶"到了缓冲区的中间。程序刚启动没几步,一压栈就把缓冲区数据踩掉了,最后表现为初始化数据莫名错乱。后来排查时才意识到,栈顶不只是个符号,它必须严格等于 RAM 段末尾,而不是某个近似值。

2.3 汇编启动的四个动作:关中断、设栈、清 BSS、跳转

LiteOS-M 在 Cortex-M 上的启动汇编,无论具体实现细节如何,核心动作基本可以归纳为四步。我按执行顺序逐个说明。

第一步,关中断。常见写法是直接操作PRIMASK或者BASEPRI,在进入 C 代码之前把全局中断屏蔽掉。原因很简单:此时栈指针虽然设置了,但中断向量表可能还没准备好,全局变量还没初始化,任何中断到来都会让 CPU 跳到一个不可预知的状态。先关中断,让系统在"受控且确定"的环境里把基本环境搭好。

第二步,设置栈指针。虽然 CPU 复位时已经从向量表第 0 项拿到了初始 MSP 并自动设置好了,但很多启动汇编出于保险考虑,还是会显式地写一次栈顶地址,因为后续可能需要切换到进程栈 PSP,这里先把主栈指针钉稳。

第三步,清零 BSS 段。所有未初始化但默认值应为 0 的全局变量都在 BSS 段,内核里很多标志位、链表头都依赖"启动时全为 0"这个前提。这一步通常用循环从__bss_start一直清到__bss_end。同时,如果数据段要从 Flash 复制到 RAM,也是在这个阶段完成的。

第四步,跳转 C 入口。对于 LiteOS-M 工程,C 入口就是main函数。从这一步开始,启动接管权从汇编转移到了 C 语言,后面的世界就顺理成章多了。

这里有一个容易忽略的细节:跳转之前,栈上不能有残留的临时数据,因为 C 语言的函数调用约定要求入口时栈是干净的。如果启动汇编里有额外的压栈操作但忘记恢复平衡,跳进 main 后第一句push可能就会异常。

3. main 之前的世界:时钟、内存与中断控制器的三方会诊

3.1 进入 main 之后,内核初始化是怎么"抢跑"的

main函数在 LiteOS-M 工程里看起来很简单,但短短几行背后是一系列精确排序的初始化。以典型工程为例,main函数里通常只有三件大事:硬件平台初始化、调用LOS_KernelInit初始化内核、调用LOS_StartToRun启动系统。

LOS_KernelInit之所以是内核初始化的核心,是因为它把内存、调度器、软件定时器、tick 时钟等模块按依赖顺序全部拉起来。在 LiteOS-M 的设计里,这个函数内部会依次做这么几件事:

  1. 调用内存初始化相关逻辑,把系统堆内存池建立起来。这个内存池是万事之母:任务控制块、任务栈、信号量、互斥锁、消息队列,全都要从这里分配。如果内存池没建立,后面的创建动作都会失败。
  2. 初始化调度器核心数据结构。比如就绪队列、任务排序链表、当前任务指针、空闲任务和软件定时器任务的占位。调度器数据结构的初始化必须是"无运行时环境也能做"的纯内存操作。
  3. 创建空闲任务和软件定时器任务。这两个任务是内核自己"走后门"创建的,不需要业务代码参与。
  4. 配置 tick 相关资源。包括 SysTick 定时器的重装载值计算、中断优先级设置,以及 tick 处理函数的注册。

看起来只是几个函数调用,但这里的先后顺序非常严格。比如,软件定时器任务的创建依赖内存池已经初始化;tick 中断的启动依赖调度器数据结构已经就绪;任务切换机制的注册依赖向量表已经准备完整。任何一个步骤错位,都可能让后面调度器启动时挂掉。

3.2 系统堆是"地基中的地基",怎么来的

在 LiteOS-M 里,系统堆不是 malloc 出来的,而是在链接阶段就划定的一块 RAM 区域,由g_sysMemStart和g_sysMemSize这两个链接脚本符号告知内核。LOS_KernelInit内部的内存初始化逻辑拿到这两个地址后,会在这块区域上构建一个内存池管理结构。

为什么这块区域必须由链接脚本来定?因为内核在启动初期还没有"动态分配 RAM"的能力,它必须先有一个静态划定的合法 RAM 区域,然后才能在这个区域上实现内存池的初始化和后续的动态分配。这有点像刚搬进新房子时,你得先有一个固定的落脚点,才能开始布置家具。

实际工程中,这块堆的大小往往就是.ld文件里 RAM 段减去栈大小、减去数据段和 BSS 段之后剩下的部分。如果你的业务任务特别多、任务栈特别大,就需要在链接脚本里预留足够的堆空间。我见过不少移植项目启动后挂在LOS_MemInit附近,本质原因就是链接脚本给堆留的地址越界了,或者堆大小算成了负数。

3.3 SysTick 优先级:调度器能正常运转的隐形前提

很多人在启动排查时会忽略一个角色:SysTick 的中断优先级配置。在 Cortex-M 内核里,SysTick 是一个内核级定时器,它的中断号是固定的。LiteOS-M 启动时需要设置 SysTick 的重装载值,决定每个 tick 的时间间隔,同时还要设置它的中断优先级。

如果 SysTick 优先级设置得不合适,问题很隐蔽。比如,SysTick 优先级被设成了 0(最高优先级),那么它就可以抢占几乎所有的临界区操作。调度器在修改就绪队列时通常要进入临界区保护,如果 SysTick 中断随时插进来,临界区保护就形同虚设,最终可能导致任务队列状态错乱,表现为"系统跑一段时间就死掉"而不是"启动就死"。

我通常的做法是:在启动早期就把 SysTick 的优先级设置为一个可以被核心管理操作屏蔽、但又能被普通外设中断打断的等级,同时把 PendSV(任务切换软中断)的优先级设置得更低。这样 tick 中断负责计时,PendSV 负责"延迟到安全时刻再做任务切换",各司其职。启动顺序上,必须先配好优先级,再使能中断,次序反了就会在初始化过程中冒出一个提前的 tick 中断,打乱一切。

4. 调度器点火:从"裸奔"到"第一个任务"的关键一瞬

4.1 LOS_StartToRun 到底做了什么,为什么是不可返回的

当LOS_KernelInit完成初始化后,main会调用LOS_StartToRun。从命名来看是"开始运行",但这个函数实际上代表了一个不可逆的转折点:调用它之后,CPU 的控制权将从主流程交给调度器,本质上不会再回到main里这行调用之后继续执行。

为什么不可返回?因为在LOS_StartToRun内部,调度器会记录当前 CPU 的上下文,把当前执行流"冻结"下来,然后从就绪队列中挑出最高优先级的任务,把 CPU 上下文切换过去。这样一来,CPU 的栈指针、PC、寄存器状态都变成了目标任务的内容。虽然从代码上看,后续可能还有几行汇编在"执行",但那些汇编已经是在新任务的上下文中执行了。

我在理解这个概念时,用了一个生活类比:想象你是一个电影院放映员,你正在控制室里做最后检查(这就是 main 主干流程),当你启动自动放映系统后,放映系统接管了一切,你这个人就从放映员变成了最前排一个看客,你的"物理位置"被换到了观众席。LOS_StartToRun之后的代码段,本质上只是给你看的一个"幻觉",真正的执行权已经跑到任务上下文里去了。

4.2 OsSchedStart 内部的链条:从 tick 启动到上下文切换

LOS_StartToRun内部真正干活的是调度器启动逻辑,在 LiteOS-M 源码里通常体现为OsSchedStart等函数。它做的事情大致分成三段。

第一段,为任务切换准备"硬件地基"。包括确认当前使用的栈指针类型、配置任务切换所需的异常机制(在 Cortex-M 上是 SVC 和 PendSV)、确保 SysTick 已经配置好重装载值和中断优先级。

第二段,启动 tick 计时。这一步通常就是OsTickStart之类的手段让 SysTick 真正开始计数并使能中断。从这一刻起,"时间"这个概念正式进入了系统,后续任务的延时、时间片轮转、软件定时器都有依赖。

第三段,触发第一次上下文切换。调度器会从就绪队列中找出第一个准备就绪的任务(通常是软件定时器任务,或者是用户在main里创建的业务任务),然后通过异常机制切换到该任务。切换完成后,启动代码里的恶龙任务正式退场。

很多人以为"启动调度器"就是简单的while(1)循环,实际上不是。调度器启动后,CPU 是一路从任务切换异常杀回到线程模式,然后带着第一个任务的上下文从任务的入口函数开始跑的。这个过程里,栈指针从一个特殊初始状态切换到了任务栈,寄存器也从启动值换成了任务入口参数,非常精妙。

4.3 空闲任务与软件定时器任务:内核自带的"两条后手"

在LOS_KernelInit阶段,LiteOS-M 会预先创建两个内核任务:空闲任务和软件定时器任务。它们的创建时机必须早于LOS_StartToRun,因为调度器一旦启动,就要求就绪队列里至少有一个可以运行的实体。

空闲任务的作用是"纯兜底"。它的优先级最低,当所有业务任务都阻塞等待事件或延时到时,CPU 才会切入空闲任务。空闲任务的循环里通常是对 CPU 低功耗模式的处理,或者至少是一个不会返回的循环,保证系统永不"无任务可跑"。

软件定时器任务则承担了定时器回调的异步执行。它通常位于一个较高的优先级,或者以独立任务的形式存在,当 tick 触发软件定时器到期事件时,内核会向这个任务发送信号,然后由这个任务在执行上下文中调用用户的定时器回调函数。这保证了定时器回调不会在中断上下文里执行,因为中断上下文里调用复杂的用户代码风险很高。

这两个任务的创建细节,决定了你在业务代码里能不能创建自己的任务。比如,如果系统堆内存太小,调度器还没跑起来,空闲任务或软件定时器任务创建失败,后来你就算调用LOS_TaskCreate也会因为内存不足而报错。所以启动早期任何一步失败,最终都可能表现为"业务任务创建失败"这种看似毫不相关的问题。

5. 移植 liteos_m 到新 MCU 时,启动链路常见的 4 个坑

5.1 启动就卡死在 OsTickInit 附近:SysTick 优先级与中断使能顺序

我第三次移植 LiteOS-M 时,遇到一个现象:代码跑进LOS_KernelInit后,在 SysTick 相关初始化附近开始异常。起初我以为是时钟频率配置错了,反复查了 RCC 分频系数,但问题依旧。后来开了单步调试,发现不是在配置 SysTick 重装载值时挂的,而是在开中断之后,一个 tick 中断立刻进来,但此时调度器数据结构还未完全就绪,中断服务程序访问了空指针。

这个问题的根因,是OsTickInit里先使能了 SysTick 中断,而后面的调度器初始化工作还没完成。在大多数参考实现里,初始化顺序通常会把"配置 SysTick 但暂不使能中断"和"使能中断"拆开,确保所有数据结构都准备好后才开始让 tick 中断运行。如果你的移植版本把这两步合在一起,或者你在自己的工程里提前使能了 SysTick,就会出现类似崩溃。

建议的做法是在启动阶段,把 SysTick 的使能动作放在调度器启动的最后一段,并确认 PendSV 的优先级配置已经就绪后再开中断。

5.2 调度器启动了,但永远跑不到业务任务:任务优先级与 idle 任务的关系

另一个高频问题是:LOS_StartToRun调用成功了,看起来没死,系统似乎也在跑,但断点打在业务任务入口,却发现永远执行不到。这个大概率是因为你创建的业务任务优先级低于空闲任务,或者任务在创建后没有正确进入就绪态。

我之前在某个演示工程里,因为抄参考代码时把任务优先级写成了 0,而 LiteOS-M 的优先级数值通常越小优先级越高,空闲任务又常被设定为最低优先级(数值最大)。如果业务任务优先级数值大于空闲任务,那么调度器启动后,只会运行空闲任务,业务任务永远排不上。

排查方法很简单:在启动后、LOS_StartToRun之前,打印或断点查看任务优先级与空闲任务优先级的比较;同时确认LOS_TaskCreate的返回值是成功,而不是因为栈分配失败被拒绝了。

5.3 进不了 main 函数:链接脚本入口符号与向量表段放置位置

第四种常见坑是程序烧录后完全没有任何反应,调试器复位后 PC 停在 0 或一个奇怪地址。这种问题基本可以直接锁定到向量表或链接脚本,而不是 C 代码逻辑。

通常要检查两点。第一,向量表段是否被链接到芯片指定的起始地址。很多 MCU 默认从 0x08000000 启动 Flash,如果你的链接脚本把向量表放到了其他位置,CPU 复位后读到的就是空白地址。第二,向量表第 0 项是不是有效的栈顶。如果栈顶被链接到一个不存在或压缩过的地址,CPU 复位后第一步设置 MSP 就会异常。

我加了个小技巧:在启动汇编最开头放一条指令设置一个固定的特殊寄存器值,如果调试器复位后看到这个寄存器被正确赋值,说明入口执行到了;如果 PC 直接飞掉,那问题绝对在向量表与链接脚本,不用浪费时间去查 C 代码。

5.4 内存初始化直接挂掉:RAM 段布局与系统堆边界越界

最后这个坑最隐蔽,也最致命。因为它的表现往往不是立刻死机,而是系统跑一段时间后随机崩溃。比如,某个任务的局部变量把相邻堆内存的内容覆盖了,表现为内核的链表节点指针被莫名改写。

这类问题的根源,就是链接脚本中栈、堆、数据段、BSS 段的布局没有做好隔离,尤其是栈和堆的边界重叠。当我把系统堆设成一个固定大小后,任务栈一旦分配得过大,堆内存就会向栈方向扩张;反过来,主栈还在往下生长,两边就在某个随机时刻撞在一起。

解决这个问题的思路,是在链接脚本里给主栈和系统堆之间留下足够的"安全缓冲",并且在启动早期用一个简单的哨兵模式内存检测,周期性检查栈顶附近的值有没有被踩坏。对于 liteos_m 这类 MCU 内核,内存本来就是稀缺资源,但宁可牺牲一点点堆大小,也要保证栈与堆的边界清晰,否则排错成本会成倍增加。

结尾:一点补充与后续建议

如果让我总结这段研究历程里最有价值的心得,那就是:任何内核启动问题,都不要先怀疑别人写好的启动文件,而是先自己把启动链路完整走一遍,确认每一环的输入输出是否符合预期。内核启动就像一串多米诺骨牌,你花一小时看懂第一块骨牌是怎么倒下去的,后面所有骨牌的问题都能顺藤摸瓜找到。

最后再分享一个小技巧:调试启动代码时,不要只在 main 打断点。在复位向量入口、LOS_KernelInit入口、LOS_StartToRun入口分别设置断点,然后记录每个断点是否命中、命中的顺序如何,这一下就能把问题范围缩小到具体环节。如果你刚刚开始接触 ohos liteos_m 的内核启动,拿一块开发板、打开反汇编窗口,从第一条指令单步走一遍,收获会比读十篇文章都大。后面如果卡在任务切换细节上,再继续深挖 PendSV 的上下文切换机制,那又是一片全新的知识领域。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进