Nuttx - Boot Flow

从 CPU 上电复位到 NSH Shell 出现提示符,NuttX 内核经历了汇编级 MMU 初始化、OS 子系统逐层构建、ELF 可执行文件加载和用户态地址空间创建四个阶段。本文以 qemu-armv7a:knsh 配置为实例,逐行追踪完整的启动调用链。


1. 启动流程全景

处理器上电后,要运行一个用户程序(比如 shell),需要解决三个问题:

  1. 硬件就绪:CPU 从物理地址执行,没有 MMU 映射,没有 C 运行环境(栈、.bss、.data)。
  2. OS 就绪:需要初始化调度器、内存管理、文件系统、中断、时钟等 OS 子系统。
  3. 用户程序就绪:KERNEL 模式下,用户程序是独立的 ELF 可执行文件,需要加载、创建独立的地址空间、从内核态切换到用户态执行。

NuttX 官方文档将这一过程划分为三个 Phase(参见 Documentation/implementation/nuttx_initialization_sequence.rst),再加上 KERNEL 模式特有的用户态准备,实际形成 四个阶段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
                        Physical Address Space      Virtual Address Space
(MMU Disabled) (MMU Enabled)
| |
Phase A ────────────────────┤ |
vector table → __start | |
arm_head.S (MMU init) |──── MMU Enabled ─────────→ |
├─ Clear L1 Page Table | |
├─ Map .text Region | |
├─ Set TTBR/TTBCR/DACR | |
└─ mov pc, lr ──────────────────────────────────────→ |
| |
Phase A (cont.) ──────────────────────────────────────── |
.Lvstart | |
├─ Set Stack Pointer | |
├─ Clear .bss / .data | |
└─ arm_boot() → nx_start() |
| |
Phase B ──────────────────────────────────────────────── |
nx_start() (~520 lines) | |
├─ Task/Mem/FS/IRQ init | |
├─ up_initialize() | |
└─ nx_bringup() | |
| |
Phase B (cont.) ──────────────────────────────────────── |
nx_bringup() | |
├─ Env vars, Work Queues | |
├─ board_late_initialize()| |
└─ exec_spawn("/bin/init")|── Create User Addr ──────→ |
| Space |
Phase C ─────────────────────────────────────────────────┤
NSH Shell Start | |
nsh_consolemain() loop | User Space 0x80000000 |
(waiting for cmd input) | |

与 Linux 的对比:Linux 启动分为 bootloader → kernel decompression → start_kernel() → rest_init() → init 进程,整体结构类似,但 NuttX 的实现更紧凑——整个启动过程从头到尾在一个函数调用链中完成,直到 exec_spawn() 才创建第一个用户态进程。

下面我们逐阶段深入源代码。


2. Phase A — 汇编级启动:从复位到 C 函数

2.1 为什么需要汇编启动?

C 语言需要栈才能运行,需要已初始化的全局变量(.data)和已清零的未初始化变量(.bss)。在 CPU 上电瞬间,这些都不存在。此外,ARMv7-A 处理器刚上电时 MMU 是关闭的,所有地址都是物理地址。我们需要用汇编语言准备 C 运行环境,并建立 MMU 页表,才能跳转到 C 代码。

2.2 复位向量:第一条指令

ARMv7-A 的复位向量在地址 0x00000000(低向量配置,CONFIG_ARCH_LOWVECTORS=y)。链接脚本将 .vectors 段放在镜像最开头:

1
dramboot.ld:55   *(.vectors)   → ROM 起始位置 (0x00000000)

向量表位于 arch/arm/src/armv7-a/arm_vectortab.S:60-73,跳转目标地址定义在 :84-99

1
2
3
4
5
6
7
8
9
10
11
12
arm_vectortab.S:60-73
.section .vectors, "ax"

_vector_start:
ldr pc, .Lresethandler /* 0x00: Reset */
ldr pc, .Lundefinedhandler /* 0x04: Undefined instruction */
ldr pc, .Lsvchandler /* 0x08: Supervisor Call (SVC) */
ldr pc, .Lprefetchaborthandler /* 0x0c: Prefetch abort */
ldr pc, .Ldataaborthandler /* 0x10: Data abort */
ldr pc, .Laddrexcptnhandler /* 0x14: Reserved */
ldr pc, .Lirqhandler /* 0x18: IRQ */
ldr pc, .Lfiqhandler /* 0x1c: FIQ */

这些跳转目标在编译时由链接器填充(arm_vectortab.S:84-99):

1
2
3
4
5
6
7
8
arm_vectortab.S:84-99
.Lresethandler:
.long __start /* 复位向量 → __start */
.Lundefinedhandler:
.long arm_vectorundefinsn
.Lsvchandler:
.long arm_vectorsvc
...

每条 ldr pc, ... 指令从紧跟的 .long 地址加载处理函数入口并跳转。复位向量指向 __start 函数,位于 arch/arm/src/armv7-a/arm_head.S:181

与 Cortex-M 的不同:Cortex-M 的向量表前两项是”初始 SP + 复位入口地址”,硬件自动加载 SP 再跳转。Cortex-A 没有这个硬件约定,需要软件自行处理所有事情。

2.3 __start:建立 MMU 页表

__start 是第一个被执行的函数,它要完成的任务是:在物理地址空间中,手工填写 L1 页表,然后打开 MMU,让 CPU 切换到虚拟地址空间运行

2.3.1 进入安全状态

1
2
3
4
5
6
7
8
9
arm_head.S:220-232

__cpu0_start:
cpsid if, #PSR_MODE_SYS /* 进入 SYS 模式,禁用 IRQ 和 FIQ */

mrc CP15_SCTLR(r0) /* 读取 SCTLR */
bic r0, r0, #(SCTLR_M | SCTLR_C) /* 禁用 MMU 和 D-Cache */
bic r0, r0, #(SCTLR_I) /* 禁用 I-Cache */
mcr CP15_SCTLR(r0)

为什么要禁用 MMU 再重新配置?上电后的 SCTLR 状态是不确定的(可能被 bootloader 修改过),必须重置到已知状态。此外,在填写页表期间 MMU 必须关闭,否则 TLB 中可能残留旧的映射。

2.3.2 清零 L1 页表

页表位于物理地址 PGTABLE_BASE_PADDR,这个值是由 chip.h:45 计算出来的:

1
2
3
4
5
chip.h:44-49
#define PGTABLE_SIZE 0x00004000 /* 16KB */
#define PGTABLE_BASE_PADDR (CONFIG_RAM_START + CONFIG_RAM_SIZE - PGTABLE_SIZE * CONFIG_SMP_NCPUS)
/* = 0x40000000 + 0x01000000 - 0x4000 * 1 */
/* = 0x40FFC000 */

代入实际值:CONFIG_RAM_START=0x40000000CONFIG_RAM_SIZE=16777216 (16MB),CONFIG_SMP_NCPUS=1,所以页表位于 0x40FFC000——物理 RAM 的最顶端。这是因为低向量表要放在 0x00000000,页表如果也放在低地址会冲突,所以放在高地址。

1
2
3
4
5
6
7
8
9
10
11
12
arm_head.S:237-248
ldr r5, .LCppgtable /* r5 = 页表物理基址 = 0x40FFC000 */
mov r0, r5
mov r1, #0
add r2, r0, #PGTABLE_SIZE /* r2 = 页表结束地址 */
.Lpgtableclear:
str r1, [r0], #4 /* 每次写 4 字节零,共 16KB / 4 = 4096 次 */
str r1, [r0], #4
str r1, [r0], #4
str r1, [r0], #4
teq r0, r2
bne .Lpgtableclear

这里使用循环展开(4 个 str 一组)来加速清零,避免分支预测失败的开销。

2.3.3 映射内核 .text 区域

ARMv7-A 的 L1 页表使用 段(section) 映射,每个 32-bit 页表项映射 1MB 地址空间。虚拟地址的 [31:20] 位作为页表索引。

对于 knsh 配置,镜像直接加载到 RAM 中运行(CONFIG_BOOT_RUNFROMFLASH=n),FLASH 和 RAM 物理地址相同(identity mapping),所以 CONFIG_IDENTITY_TEXTMAP=1。这意味着不需要创建临时 identity 映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
arm_head.S:285-321
adr r0, .LCtextinfo /* 加载文本段描述信息的地址 */
ldmia r0, {r1, r2, r3, r4} /* r1=物理地址, r2=虚拟地址, r3=MMU标志, r4=段数 */

/* .LCtextinfo 中的实际值 (arm_head.S:602-614):
* r1 = NUTTX_TEXT_PADDR = 0x00000000 (CONFIG_FLASH_VSTART & 0xfff00000)
* r2 = NUTTX_TEXT_VADDR = 0x00000000
* r3 = MMU_MEMFLAGS (可读写, 可缓存, domain 0)
* r4 = 1 (1 个 1MB 段)
*/

add r2, r5, r2, lsr #18 /* r2 = 页表基址 + (虚拟地址>>18) = 页表项地址 */
.Lpgtextloop:
orr r0, r1, r3 /* r0 = 物理地址 | MMU 标志 */
subs r4, r4, #1 /* 段计数减 1 */
str r0, [r2], #4 /* 写入页表项,指针递增 */
add r1, r1, #(1024*1024) /* 物理地址 +1MB */
bne .Lpgtextloop

实例 — 第一个页表项的构造

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
页表基址       = 0x40FFC000
虚拟地址 = 0x00000000
页表索引 = 0x00000000 >> 20 = 0
页表项地址 = 0x40FFC000 + 0*4 = 0x40FFC000

页表项值:
物理地址 = 0x00000000
MMU_MEMFLAGS = PMD_TYPE_SECT | PMD_SECT_AP_RW1 | PMD_CACHEABLE | PMD_SECT_DOM(0)
= (2<<0) | (1<<10) | ((1<<2)|(1<<3)) | (0<<5)
= 0x0000040E
最终值 = 0x00000000 | 0x0000040E = 0x0000040E

结果:
虚拟地址 0x00000000 ~ 0x000FFFFF → 物理地址 0x00000000 ~ 0x000FFFFF
权限:内核态可读写,可缓存,domain 0

实际 MMU_MEMFLAGS 的完整拼装(arch/arm/src/armv7-a/mmu.h:610-615):

1
2
3
4
5
6
7
8
mmu.h:610-615
#define MMU_MEMFLAGS (PMD_TYPE_SECT | PMD_SECT_AP_RW1 | PMD_CACHEABLE | \
PMD_SECT_DOM(0))
/* 展开: PMD_TYPE_SECT = (2 << 0) Section 描述符
* PMD_SECT_AP_RW1 = (1 << 10) AP[1:0]=01, PL1 读写, PL0 无访问
* PMD_CACHEABLE = (1 << 2)|(1<<3) C=1, B=1, Write-Back 缓存
* PMD_SECT_DOM(0) = (0 << 5) Domain 0
*/

PMD_TYPE_SECT(值为 2)标记这是一个 1MB 段描述符。PMD_SECT_AP_RW1 设置 AP[1:0]=01,表示只有 PL1(内核态)可以读写,PL0(用户态)无访问权限——这保证了内核内存不被用户进程访问。

2.3.4 无效化 TLB 和 Cache

填写完页表后,必须无效化所有 TLB(转换后备缓冲器),因为 CPU 可能缓存了旧的映射。同时无效化分支预测器和 I-Cache:

1
2
3
4
5
6
7
arm_head.S:377-389
mov r0, #0
mcr CP15_TPIDRPRW(r0) /* 初始化每 CPU 寄存器 */
mcr CP15_TLBIALLIS(r0) /* 无效化所有 TLB (IS: Inner Shareable) */
mcr CP15_BPIALLIS(r0) /* 无效化分支预测器 */
mcr CP15_ICIALLUIS(r0) /* 无效化 I-Cache */
isb

TLBIALLISIS 后缀表示操作广播到 Inner Shareable 域内的所有 CPU 核心——这在多核(SMP)场景下是必要的。isb(指令同步屏障)保证后续指令能看到 TLB 已清空。接下来就可以安全地配置 MMU 寄存器了。

2.3.5 配置 MMU 寄存器并开启

设置完 TLB 后,需要配置一系列 CP15 协处理器寄存器,将页表地址告诉 MMU,然后打开 MMU 和 Cache。以下代码取自 arm_head.S:407-559,展示了 knsh 配置下的有效执行路径(完整源代码中有多个 #ifdef 分支处理 SMP、Cortex-A5、高向量、对齐陷阱、大小端等变体,此处只保留 knsh 实际走的分支):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
arm_head.S:407-559
orr r1, r5, #(TTBR0_RGN_WBWA | TTBR0_IRGN0) /* 页表基址 + 缓存属性 */
mcr CP15_TTBR0(r1) /* 设置转换表基址寄存器 0 */
mcr CP15_TTBR1(r1) /* 设置转换表基址寄存器 1 */
mcr CP15_TTBCR(r0) /* TTBCR=0: 使用 TTBR0, 16KB 页表 */

ldr lr, .LCvstart /* lr = .Lvstart 的虚拟地址 */
mov r0, #DACR_CLIENT(0)
mcr CP15_DACR(r0) /* Domain 0 = Client (使用页表权限) */

/* 配置 SCTLR: */
mrc CP15_SCTLR(r0)
bic r0, r0, #(SCTLR_A | SCTLR_C)
bic r0, r0, #(SCTLR_SW | SCTLR_I | SCTLR_V | SCTLR_RR | SCTLR_HA)
bic r0, r0, #(SCTLR_EE | SCTLR_TRE | SCTLR_AFE | SCTLR_TE)
orr r0, r0, #(SCTLR_M) /* 开启 MMU */
orr r0, r0, #(SCTLR_C) /* 开启 D-Cache */
orr r0, r0, #(SCTLR_I) /* 开启 I-Cache */
mcr CP15_SCTLR(r0) /* 写回 SCTLR → MMU 正式启用! */
isb
.rept 12
nop /* Cortex-A8 需要等流水线排空 */
.endr

mov pc, lr /* 跳转到 .Lvstart (虚拟地址) */

关键一步mov pc, lr 后,CPU 正式在虚拟地址空间中运行。由于我们做了 identity mapping(物理地址 = 虚拟地址),代码可以无缝继续执行。

2.4 .Lvstart:准备 C 运行环境

进入 .Lvstartarm_head.S:660)后,MMU 已开启,我们需要建立栈并初始化数据段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
arm_head.S:660-708
.Lvstart:
/* 设置栈指针和帧指针 */
ldr sp, .Lstackpointer /* sp = IDLE_STACK_TOP */
bic sp, sp, #7 /* 8 字节对齐 */
mov fp, #0

/* 初始化 .bss (清零) 和 .data (从 ROM 复制初始值) */
bl arm_data_initialize

/* 平台特定的 C 级初始化 */
bl arm_boot

/* 跳转到 NuttX 调度器入口 */
mov lr, #0
b nx_start

栈指针指向 IDLE_STACK_TOP,其定义在 arm_head.S:37-45

1
2
3
4
5
6
7
8
9
10
11
arm_head.S:37-45
#ifndef IDLE_STACK_BASE
#ifdef CONFIG_BOOT_SDRAM_DATA
#define IDLE_STACK_BASE IDLE_STACK_VBASE
#else
#define IDLE_STACK_BASE _ebss /* 栈紧接 BSS 段末尾 */
#endif
#endif

#define IDLE_STACK_TOP (IDLE_STACK_BASE + CONFIG_IDLETHREAD_STACKSIZE)
/* = _ebss + 4096 */

_ebss 由链接脚本定义(dramboot.ld:115-120),位于 .bss 段的末尾。栈向下增长,栈顶在 _ebss + 4096 处。内核堆从 g_idle_topstack(即 IDLE_STACK_TOP)开始向上延伸。

实例 — 内存布局计算

1
2
3
4
5
6
7
8
9
10
11
12
假设 .text + .rodata 共占用 0x20000 (128KB)
.data 占用 0x1000 (4KB)
.bss 占用 0x8000 (32KB)

_ebss = CONFIG_RAM_START + 0x20000 + 0x1000 + 0x8000
= 0x40000000 + 0x29000 = 0x40029000

IDLE_STACK_TOP = 0x40029000 + 4096 = 0x4002A000
g_idle_topstack = 0x4002A000

内核堆范围: [0x4002A000, PGTABLE_BASE_PADDR) = [0x4002A000, 0x40FFC000)
≈ 0xFD2000 约 15.8MB

arm_data_initialize 函数(arm_head.S:719-755)负责清零 .bss 段:

1
2
3
4
5
6
7
8
arm_head.S:719-731
arm_data_initialize:
adr r0, .Linitparms
ldmia r0, {r0, r1} /* r0=_sbss, r1=_ebss */
mov r2, #0
1: cmp r0, r1
strcc r2, [r0], #4 /* 逐 4 字节写零 */
bcc 1b

由于 knsh 配置下镜像直接加载到 RAM(不是从 Flash 运行),.data 的初始值已经在加载时被 QEMU 放入正确位置,所以 CONFIG_BOOT_RUNFROMFLASH 为假,不需要复制 .data

做完 .bss 清零后,汇编阶段的最后一步是调用 arm_boot() 跳入 C 世界。这标志着从纯粹的手工寄存器操作过渡到结构化的平台初始化代码。


3. Phase A 续 — arm_boot():平台级 C 初始化

__start 在汇编阶段只是”够用就行”地映射了内核代码所在的 1MB 区域。如果要让操作系统正常工作,还需要访问 MMIO 外设寄存器(如 GIC 中断控制器、UART 串口)、配置浮点单元、以及让剩余的所有物理内存区域都变得可访问。这些工作无法全在汇编中完成——C 语言的表达能力远强于汇编,而且 MMU 映射 API (mmu_l1_map_regions) 本身就是 C 函数。

因此 .Lvstart 调用 arm_boot()arch/arm/src/qemu/qemu_boot.c:77),把后续的平台初始化交给 C 代码:

1
2
3
4
5
6
7
8
9
qemu_boot.c:77,88-112
void arm_boot(void)
{
qemu_setupmappings(); /* 补充 MMU 映射 */
arm_fpuconfig(); /* 配置 FPU */
arm_psci_init("hvc"); /* PSCI 电源管理 */
fdt_register(0x40000000); /* 注册设备树 */
arm_earlyserialinit(); /* 早期串口初始化 */
}

(完整函数还包含 CONFIG_ARCH_PERF_EVENTSCONFIG_SMPCONFIG_BUILD_PROTECTED 等条件编译分支,knsh 配置下均不生效。)

这五个初始化步骤中,对启动流程最关键的是 qemu_setupmappings()——它把 QEMU virt 平台上的全部物理内存和外设区域映射进 MMU 页表。之后初始化 FPU(否则浮点指令会触发未定义异常)和串口(否则看不到任何输出)。

3.1 完善 MMU 映射 — qemu_setupmappings()

QEMU virt 平台的物理内存布局如下(qemu_memorymap.h:43-63):

1
2
3
4
5
6
0x00000000 ────────────────  Flash (128MB)
0x08000000 ──────────────── I/O (96MB): GIC, UART, RTC 等
0x0E000000 ──────────────── Secure Memory (16MB)
0x10000000 ──────────────── PCIe ECAM (768MB)
0x40000000 ──────────────── DDR RAM (256MB)
0x50000000 ────────────────

qemu_setupmappings() 遍历预定义的映射表,为所有区域建立 L1 段映射:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
qemu_memorymap.c:44-85
static const struct section_mapping_s g_section_mapping[] =
{
{ VIRT_FLASH_PSECTION, VIRT_FLASH_VSECTION, MMU_MEMFLAGS, _NSECTIONS(128MB) },
{ VIRT_IO_PSECTION, VIRT_IO_VSECTION, MMU_IOFLAGS, _NSECTIONS(96MB) },
{ VIRT_SEC_MEM_PSECTION, ... MMU_MEMFLAGS, _NSECTIONS(16MB) },
{ VIRT_PCIE_PSECTION, VIRT_PCIE_VSECTION, MMU_IOFLAGS, _NSECTIONS(768MB) },
{ VIRT_DDR_PSECTION, VIRT_DDR_VSECTION, MMU_MEMFLAGS, _NSECTIONS(256MB) },
};

int qemu_setupmappings(void)
{
mmu_l1_map_regions(g_section_mapping, nitems(g_section_mapping));
// → mmu_l1_map_region() → mmu_l1_setentry() 循环写页表项
}

注意 I/O 区域使用 MMU_IOFLAGS(Device 类型,禁止缓存、禁止执行),而内存区域使用 MMU_MEMFLAGS(Write-Back 缓存、允许执行)。所有区域都是 identity mapping(虚拟地址 = 物理地址),因为 VIRT_*_VSECTION == VIRT_*_PSECTION

为什么 identity mapping? KERNEL 模式中,内核运行在 PL1 特权级,访问物理地址等同于访问虚拟地址。用户态进程才有独立的虚拟地址空间(0x80000000 起)。这种设计简化了内核驱动编写——内核可以直接使用物理地址访问设备寄存器。

至此,硬件层面的一切准备就绪:MMU 已开启、所有物理区域已映射、FPU 和串口已配置。接下来,执行流进入 nx_start(),从”平台初始化”切换到”操作系统构建”阶段。


4. Phase B — nx_start():OS 内核初始化

nx_start() 是整个 NuttX 内核的入口,位于 sched/init/nx_start.c:502。它是所有初始化步骤的编排器,按照严格的顺序逐层构建 OS 基础设施。

4.1 初始化状态机

NuttX 使用 g_nx_initstate 全局变量跟踪初始化进度(include/nuttx/init.h:57-78):

1
2
3
4
5
6
7
OSINIT_POWERUP (0)  →  刚上电
OSINIT_BOOT (1) → boot 完成,进入 nx_start
OSINIT_TASKLISTS (2)→ 任务链表就绪
OSINIT_MEMORY (3) → 内存管理器可用
OSINIT_HARDWARE (4) → 硬件驱动就绪
OSINIT_OSREADY (5) → OS 准备多任务
OSINIT_IDLELOOP (6) → 进入 IDLE 循环

每个阶段的切换都很关键——后续代码可以通过 OSINIT_MM_READY() 等宏检查某个子系统是否已经就绪。

4.2 创建 IDLE 任务

IDLE 任务是系统中优先级最低(priority=0)且永远不可被阻塞的特殊任务。它有两个功能:

  1. 在系统无其他任务可运行时占位(防止调度器空转)
  2. 作为所有后续任务的”祖先”——子任务继承其环境变量和文件描述符

idle_task_initialize()nx_start.c:328-420。以下展示核心逻辑(完整函数还包含 SMP 多 CPU IDLE 栈分配、名称设置和 per-CPU 寄存器更新,为简洁省略了相关代码路径):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
nx_start.c:328,336-347,355-366,372-387,405-413
memset(g_idletcb, 0, sizeof(g_idletcb));
for (i = 0; i < CONFIG_SMP_NCPUS; i++) {
tcb = &g_idletcb[i];
tcb->pid = i; /* CPU0 IDLE pid=0 */
tcb->task_state = TSTATE_TASK_RUNNING;
tcb->lockcount = 1; /* 初始化期间禁止抢占 */

/* CPU0 IDLE 的入口是 nx_start 本身 (boot 后从这里开始执行) */
tcb->start = nx_start;
tcb->entry.main = (main_t)nx_start;

tcb->flags = TCB_FLAG_TTYPE_KERNEL; /* 内核线程 */
tcb->name = "CPU0 IDLE";

#ifdef CONFIG_SMP
g_assignedtasks[i] = tcb; /* 分配到此 CPU */
#else
dq_addfirst((FAR dq_entry_t *)tcb, TLIST_HEAD(tcb));
#endif
g_running_tasks[i] = tcb; /* 标记为正在运行 */
}

关键设计nx_start 就是 CPU0 IDLE 任务的入口函数。这意味着整个 OS 初始化(Phase B)都是在 IDLE 任务的上下文中完成的。这解释了为什么初始化期间是单线程的——IDLE 线程在执行初始化,还没有其他线程存在。

4.3 内存管理器初始化

内存初始化是第二步(因为后续所有子系统都需要动态分配内存):

1
2
3
4
5
6
7
8
9
10
11
12
nx_start.c:540-575
/* 1. 用户态堆 (仅在 PROTECTED 模式下) */
up_allocate_heap(&heap_start, &heap_size);
kumm_initialize(heap_start, heap_size);

/* 2. 内核堆 */
up_allocate_kheap(&heap_start, &heap_size);
kmm_initialize(heap_start, heap_size);

/* 3. 页分配器 (KERNEL 模式,用于用户态地址空间的物理页分配) */
up_allocate_pgheap(&heap_start, &heap_size);
mm_pginitialize(heap_start, heap_size);

对于 knsh 配置,实际的内存布局由 qemu_allocateheap.cqemu_pgalloc.c 定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
0x40000000  ┌───────────────────────────────┐  RAM_START
│ .vectors, .text, .rodata │ ROM Region
├───────────────────────────────┤
│ .data, .bss │
├───────────────────────────────┤ _ebss
│ IDLE Stack (4096 bytes) │
├───────────────────────────────┤ g_idle_topstack
│ │
│ Kernel Heap (kmm) │
│ ~15.8 MB │
│ │
├───────────────────────────────┤ PGPOOL_PBASE = 0x40300000
│ │
│ Page Pool (mm_pginitialize) │
│ ~13 MB │ For user-space page allocation
│ │
├───────────────────────────────┤
│ L1 Page Table (16KB) │ PGTABLE_BASE_PADDR = 0x40FFC000
│ │
0x41000000 └───────────────────────────────┘ RAM_START + RAM_SIZE

实例 — 内核堆的起止地址(来自 arm_allocateheap.c:155-180):

1
2
3
4
5
6
7
8
9
10
11
12
arm_allocateheap.c:155-180
void weak_function up_allocate_kheap(void **heap_start, size_t *heap_size)
{
#ifdef CONFIG_ARCH_PGPOOL_PBASE
uintptr_t base = g_idle_topstack;
uintptr_t size = CONFIG_ARCH_PGPOOL_PBASE - g_idle_topstack;

size -= PGTABLE_SIZE * CONFIG_SMP_NCPUS;
#endif
*heap_start = (void *)base;
*heap_size = size;
}

代入 knsh 的实际值:g_idle_topstack 紧跟在 IDLE 栈之后(约 _ebss + 4096),CONFIG_ARCH_PGPOOL_PBASE = 0x40300000,所以内核堆从 IDLE 栈顶一直延伸到页池的起始位置(0x40300000),大小约 2~3 MB。堆的上方是页池,用于为用户态进程分配物理页。

4.4 OS 子系统初始化链

内存管理器就绪后,nx_start 按依赖关系依次初始化各子系统:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
nx_start.c:622-666
/* Initialize tasking data structures */

task_initialize();

/* Initialize the instrument function */

instrument_initialize();

/* Initialize the file system (needed to support device drivers) */

fs_initialize();

/* Initialize the interrupt handling subsystem (if included) */

irq_initialize();

/* Initialize the POSIX timer facility (if included in the link) */

clock_initialize();

#ifndef CONFIG_DISABLE_POSIX_TIMERS
timer_initialize();
#endif

/* Initialize the signal facility (if in link) */

#ifdef CONFIG_ENABLE_ALL_SIGNALS
nxsig_initialize();
#endif

#if !defined(CONFIG_DISABLE_MQUEUE) || !defined(CONFIG_DISABLE_MQUEUE_SYSV)
/* Initialize the named message queue facility (if in link) */

nxmq_initialize();
#endif

#ifdef CONFIG_NET
/* Initialize the networking system. Network initialization is
* performed early because networking software may have been
* initialized first.
*/

net_initialize();
#endif

#ifndef CONFIG_BINFMT_DISABLE
/* Initialize the binary format handler */

binfmt_initialize();
#endif

为什么这个顺序? 信号量必须最先初始化(nxsem_initialize() 在内存初始化前)。因为几乎所有后续子系统内部都依赖信号量进行同步。文件系统必须在设备驱动之前初始化,因为驱动需要向 VFS 注册 /dev/* 节点。

4.5 up_initialize():硬件驱动初始化

软件数据结构准备完毕,开始初始化真实硬件:

1
2
nx_start.c:676
up_initialize(); /* 调用 arch/arm/src/common/arm_initialize.c:62 */

arm_initialize.c:62-121 实际上是硬件的集中初始化点。展示核心调用(完整函数还包含电源管理、DMA、coredump 等条件编译路径):

1
2
3
4
5
6
7
8
9
arm_initialize.c:62,67,72,100,105,119
void up_initialize(void)
{
arm_initialize_stack(); /* 确认中断栈配置 */
arm_addregion(); /* 向堆添加额外内存区域 */
arm_serialinit(); /* 串口驱动 → pl011_serialinit */
arm_netinitialize(); /* 网络硬件初始化 */
arm_l2ccinitialize(); /* L2 缓存控制器 */
}

对于 qemu-armv7a,关键驱动初始化变为:

1
2
3
qemu_irq.c:105   up_irqinitialize()     /* 设置 VBAR, 初始化 GIC, 启用中断 */
qemu_timer.c:35 up_timer_initialize() /* ARM 架构定时器 (arch timer) */
qemu_serial.c:62 arm_serialinit() /* PL011 串口 */

中断初始化(qemu_irq.c:105)将 GIC 配置好,设置 VBAR(向量基址寄存器)指向异常向量表,然后向 CPU 使能 IRQ。从这一刻起,系统可以响应中断了。

4.6 状态记录

1
2
3
g_nx_initstate = OSINIT_HARDWARE;    /* nx_start.c:695, 硬件资源就绪 */
...
g_nx_initstate = OSINIT_OSREADY; /* nx_start.c:742, 准备多任务 */

OS 基础设施(调度器、内存、文件系统、中断、时钟)已经就绪,但系统中还只有一个 IDLE 线程在运行。下一步 nx_bringup() 负责”激活”这个系统——创建工作队列、启动板级驱动、并创建第一个用户进程。


5. Phase B 续 — nx_bringup():系统 Bring-Up

nx_bringup()sched/init/nx_bringup.c:496)在 nx_start 末尾被调用,完成从内核初始化到用户程序的最后一步。

5.1 设置环境变量

1
2
3
4
nx_bringup.c:508-518
setenv("PWD", CONFIG_LIBC_HOMEDIR, 1);
setenv("PATH", CONFIG_PATH_INITIAL, 1); // = "/system/bin"
setenv("LD_LIBRARY_PATH", CONFIG_LDPATH_INITIAL, 1);

这些环境变量被设置为 IDLE 任务的环境,所有后续创建的子任务都会继承。

5.2 启动内核工作线程

1
2
3
4
nx_bringup.c:526-539
nx_pgworker(); /* 页错误处理线程 (可选) */
nx_workqueues(); /* HP/LP 工作队列: device driver "bottom half" */
nx_create_initthread(); /* 创建应用初始化线程 */

nx_pgworkerCONFIG_LEGACY_PAGING 配置下创建页错误处理的内核线程,knsh 未启用此功能,所以实际被预处理器替换为空操作。nx_workqueues 启动高优先级和低优先级工作队列线程,用于设备驱动”下半部”(bottom half)处理。nx_create_initthread 是接下来创建用户进程的关键步骤。

5.3 创建应用初始化线程

nx_create_initthread()nx_bringup.c:436-456)有两个路径:

**路径 A (knsh 采用)**:配置了 CONFIG_BOARD_LATE_INITIALIZE 时,创建一个独立的内核线程:

1
2
3
4
pid = nxthread_create("AppBringUp", TCB_FLAG_TTYPE_KERNEL,
CONFIG_BOARD_INITTHREAD_PRIORITY,
NULL, CONFIG_BOARD_INITTHREAD_STACKSIZE,
nx_start_task, NULL, environ);

nx_start_tasknx_bringup.c:410-416)在独立线程中执行 nx_start_application()为什么要独立线程? IDLE 线程不允许阻塞(等待信号量、等待 I/O 等),而 board_late_initialize() 中可能需要挂载文件系统、初始化复杂设备——这些操作可能需要等待。独立线程可以安全地阻塞。

5.4 nx_start_application():启动用户程序

nx_start_application()nx_bringup.c:297-391)是启动流程的核心转折点。以下摘取 knsh 实际执行的关键路径(CONFIG_INIT_FILE 分支),完整函数还包含 /etc ROMFS 挂载、coredump 初始化和 PROTECTED/FLAT 模式下的 task_spawn 路径:

1
2
3
4
5
6
7
8
9
nx_bringup.c:316-324,362-387
board_late_initialize(); // → qemu_bringup(): 挂载 tmpfs, procfs

/* 挂载 hostfs: 开发机文件系统映射到 /system */
nx_mount("", "/system", "hostfs", MS_RDONLY, "fs=../apps");

/* CONFIG_INIT_FILEPATH = "/system/bin/init" */
ret = exec_spawn("/system/bin/init", argv, NULL,
CONFIG_INIT_SYMTAB, CONFIG_INIT_NEXPORTS, NULL, &attr);

board_late_initialize()qemu_bringup()qemu_bringup.c:161-211)挂载必要的文件系统:

1
2
3
qemu_bringup.c:161-184
nx_mount(NULL, CONFIG_LIBC_TMPDIR, "tmpfs", 0, NULL); /* /tmp */
nx_mount(NULL, "/proc", "procfs", 0, NULL); /* /proc */

tmpfs 提供内存中的临时文件系统(用于 /tmp 等路径),procfs 提供进程信息伪文件系统(/proc/<pid>/)。这两个挂载点通常在用户程序启动前就绪,因为 NSH 和用户程序可能会用到。文件系统就绪后,最后一步是通过 exec_spawn 创建用户进程。

5.5 exec_spawn():KERNEL 模式的进程创建

在 KERNEL 模式(CONFIG_BUILD_KERNEL=y)下,task_spawn() 整个文件被编译排除(sched/task/task_spawn.c:46#ifndef CONFIG_BUILD_KERNEL 保护)。用户进程只能通过 exec_spawn() 从文件系统加载 ELF 可执行文件来创建。

exec_spawn() 创建了系统中第一个用户态进程后,nx_bringup() 返回,IDLE 线程进入死循环等待被抢占。调度器发现就绪队列中有 init 任务,立即执行上下文切换,CPU 从内核态(PL1)切换到用户态(PL0),跳转到 ELF 文件的入口点。启动流程的接力棒正式从内核交给用户程序。

exec_spawn() 内部的执行路径反映了 binfmt 子系统的工作流程。先从文件系统加载 ELF 映像(load_module,解析 program headers 和 section headers),然后创建进程实例(exec_module)。创建过程中最关键的几步是:先用 addrenv_select 切换到新的用户地址空间,再用 up_addrenv_vheap 在用户虚拟地址上分配堆,最后 nxtask_activate 将任务加入就绪队列——此刻调度器可以在下一个时钟 tick 选择它运行。

1
2
3
4
5
6
7
8
9
10
11
exec_spawn("/system/bin/init")           [binfmt/binfmt_exec.c:193]
→ exec_internal() [binfmt/binfmt_exec.c:77]
→ load_module() Parse ELF sections & resolve symbols
→ exec_module() [binfmt/binfmt_execmodule.c:152]
→ addrenv_select(addr_env) Switch to user addr space
→ up_addrenv_vheap() Create user virtual heap
→ umm_initialize() Init user memory allocator
→ kmm_zalloc(sizeof(tcb_s)) Allocate TCB
→ nxtask_init(tcb, ...) Init TCB (TCB_FLAG_TTYPE_TASK)
→ addrenv_attach(tcb, addr_env) Bind addr space to task
→ nxtask_activate(tcb) Add to ready-to-run list

用户态地址空间布局(来自 defconfig 中的 CONFIG_ARCH_* 值):

1
2
3
4
5
6
7
Kernel Space (identity mapped, PL1 access):
0x40000000 ────────── Kernel Memory

User Space (PL0 access):
0x80000000 ────────── .text Region (256 pages × 4KB = 1MB) CONFIG_ARCH_TEXT_VBASE
0x80100000 ────────── .data Region (256 pages × 4KB = 1MB) CONFIG_ARCH_DATA_VBASE
0x80200000 ────────── heap Region (256 pages × 4KB = 1MB) CONFIG_ARCH_HEAP_VBASE

与 Linux 的对比:Linux 用户态从 0x08048000(32-bit)或 0x400000(64-bit)开始,内核态在高地址(0xC0000000 起)。NuttX ARM 的设计相反——内核在低地址做 identity mapping,用户态在高地址拥有独立的虚拟地址空间。这是 ARM MMU 的灵活性:每个进程可以有自己的 TTBR0。

5.6 用户程序入口

knsh 配置中 CONFIG_INIT_FILEPATH="/system/bin/init"。这个 init 程序实际上是 NSH (NuttShell),通过 hostfs 从开发机文件系统映射过来。

exec_module() 完成 TCB 初始化并调用 nxtask_activate() 后,init 任务进入就绪队列。然后 nx_bringup() 返回,nx_start() 的剩余部分继续:

1
2
3
4
nx_start.c:751-771
g_nx_initstate = OSINIT_IDLELOOP;
sched_unlock(); /* 释放调度锁,允许任务切换 */
for (;;) up_idle(); /* IDLE 循环: 执行 WFI 等待中断 */

sched_unlock() 是关键:整个初始化过程被 lockcount=1 锁住,不允许任务切换。开锁后,调度器会选择就绪队列中优先级最高的任务运行——即刚创建的 init 任务。


6. Phase C — 用户程序:NSH Shell 启动

init 任务的入口是 NSH 的 main 函数。在 nxtask_start()sched/task/task_start.c:68)中,由于任务类型是 TCB_FLAG_TTYPE_TASK(而非 KERNEL),执行路径走 up_task_start()

1
2
3
4
5
6
task_start.c:88-108
if (ttype == TCB_FLAG_TTYPE_KERNEL) {
exitcode = tcb->entry.main(argc, argv); /* 内核线程: 直接调用 */
} else {
up_task_start(tcb->entry.main, argc, argv); /* 用户任务: 切换到 PL0 */
}

up_task_start() 是架构相关的汇编函数,设置 CPU 从 PL1(内核态)切换到 PL0(用户态),然后跳转到用户空间的入口函数。用户空间运行 nxtask_startup()libs/libc/sched/task_startup.c:58),它调用 C++ 静态初始化,然后执行 main()

NSH 的 mainapps/system/nsh/nsh_main.c。启动流程分两步:nsh_initialize() 完成一次性初始化(挂载 /etc ROMFS 并执行 rcS 启动脚本),然后 nsh_consolemain() 进入死循环,不断读取用户输入、解析命令、执行。nsh_consolemain() 不返回,因此 NSH 一旦启动就永远占据控制台。其启动流程如下(参见 nuttx_initialization_sequence.rst:572-651):

1
2
3
4
5
nsh_main()
→ nsh_initialize() Init NSH lib, execute rcS startup script
→ nsh_consolemain() Console interactive loop (never returns)
→ Print "nsh> " prompt
→ Wait for user input

这标志着整个启动流程的终点:从 CPU 上电到用户可以输入命令,所有的初始化工作已经完成。


7. 完整调用链总结

下面是 qemu-armv7a:knsh 从硬件复位到 NSH 提示符的完整路径。这个调用链可以按 MMU 状态分为两个大的半场:前半场(__start.Lvstartmov pc, lr)运行在物理地址空间,负责建立页表和启用 MMU;后半场(从 .Lvstart 开始)运行在虚拟地址空间,完成 OS 初始化和用户进程创建。最关键的两个转折点是 MMU 开启(地址空间从物理切换到虚拟)和 **exec_spawn**(从内核单线程切换到多任务、从内核空间创建出用户空间的第一个进程)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
复位向量 (0x00000000)

├─ arm_vectortab.S:66 ldr pc, [__start]

└─ arm_head.S:181 __start
├─ SYS 模式, 禁用 MMU/Cache
├─ 清零 L1 页表 (16KB @ 0x40FFC000)
├─ 映射 .text 区域 (1MB section)
├─ TLB/Cache 无效化
├─ 配置 TTBR0/TTBR1/TTBCR/DACR
├─ 写 SCTLR → MMU 开启
└─ mov pc, lr → .Lvstart

├─ arm_head.S:676 SP = IDLE_STACK_TOP
├─ arm_head.S:687 arm_data_initialize (.bss 清零)
├─ arm_head.S:694 arm_boot()
│ ├─ qemu_boot.c:88 qemu_setupmappings() (映射 FLASH/IO/DDR)
│ ├─ qemu_boot.c:97 arm_fpuconfig()
│ ├─ qemu_boot.c:100 arm_psci_init("hvc")
│ ├─ qemu_boot.c:104 fdt_register(0x40000000)
│ └─ qemu_boot.c:112 arm_earlyserialinit()

└─ arm_head.S:708 b nx_start

├─ nx_start.c:515 tasklist_initialize()
├─ nx_start.c:519 idle_task_initialize() (CPU0 IDLE tcb)
├─ nx_start.c:538 nxsem_initialize()
├─ nx_start.c:562 kmm_initialize() (内核堆)
├─ nx_start.c:573 mm_pginitialize() (页池)
├─ nx_start.c:611 idle_group_initialize()
├─ nx_start.c:622 task_initialize()
├─ nx_start.c:630 fs_initialize()
├─ nx_start.c:634 irq_initialize()
├─ nx_start.c:638 clock_initialize()
├─ nx_start.c:665 binfmt_initialize()
├─ nx_start.c:676 up_initialize()
│ ├─ arm_initialize.c:100 arm_serialinit()
│ ├─ qemu_irq.c:105 up_irqinitialize() (GIC)
│ └─ qemu_timer.c:35 up_timer_initialize()

├─ nx_start.c:695 OSINIT_HARDWARE
├─ nx_start.c:742 OSINIT_OSREADY
├─ nx_start.c:747 nx_bringup()
│ ├─ nx_bringup.c:513 setenv("PATH", "/system/bin")
│ ├─ nx_bringup.c:532 nx_workqueues()
│ ├─ nx_bringup.c:539 nx_create_initthread()
│ │ └─ "AppBringUp" 内核线程
│ │ └─ nx_start_application()
│ │ ├─ board_late_initialize()
│ │ │ └─ qemu_bringup(): mount tmpfs, procfs
│ │ └─ exec_spawn("/system/bin/init")
│ │ └─ exec_module()
│ │ ├─ addrenv_select()
│ │ ├─ up_addrenv_vheap()
│ │ └─ nxtask_activate()
│ │
│ └─ return

├─ nx_start.c:751 OSINIT_IDLELOOP
├─ nx_start.c:757 sched_unlock()
└─ nx_start.c:765 up_idle() 死循环 (WFI)

│ (调度器选择 init 任务运行)

└─ NSH shell 提示符

理解了完整的调用链之后,我们来剖析几个贯穿整个启动流程的关键设计决策——这些决策决定了 NuttX KERNEL 模式与 FLAT 模式、乃至与 Linux 的本质区别。

8. 关键设计决策解析

8.1 KERNEL vs FLAT 模式的本质区别

特性 FLAT 模式 KERNEL 模式 (knsh)
用户程序格式 与内核链接在一起 独立 ELF 文件
地址空间 共享同一个物理空间 用户态独立虚拟空间
进程创建 task_spawn() exec_spawn()
内核保护 无 (单地址空间) MMU 隔离
用户堆 全局堆的一部分 up_addrenv_vheap() 分配

在 FLAT 模式下,所有”任务”都共享同一个地址空间,类似于 FreeRTOS 的任务模型。在 KERNEL 模式下,每个用户进程拥有完整的独立地址空间,更像 Linux 的进程模型。

8.2 Identity Mapping 的妙用

NuttX KERNEL 模式对内核地址空间采用 identity mapping(虚拟地址 = 物理地址)。这个设计有两个好处:

  1. 内核驱动可以直接使用物理地址访问 MMIO 寄存器,不需要 ioremap
  2. MMU 使能/禁用的过渡更平滑——代码在 MMU 开启前后看到的地址相同

代价是内核物理内存被限制在低地址范围,用户空间被挤到高地址(0x80000000 起)。

8.3 IDLE 线程即初始化线程

nx_start 就是 CPU0 IDLE 线程的入口。这个设计避免了创建单独的”启动线程”,节省了内存和上下文切换开销。代价是 IDLE 线程在初始化期间不能做 IDLE 该做的事(清理延迟释放的内存等),但在初始化完成前系统也没有其他内存分配需要清理。

以上分析了整个启动流程中三个最核心的设计决策。下面给出本文涉及的所有源文件索引,方便读者按图索骥地深入源码。

9. 参考

文件 关键内容
arch/arm/src/armv7-a/arm_vectortab.S:60-73 异常向量表,复位向量 → __start
arch/arm/src/armv7-a/arm_head.S:37-45 IDLE_STACK_BASE / IDLE_STACK_TOP 宏定义
arch/arm/src/armv7-a/arm_head.S:181-248 __start: SYS 模式进入、MMU/Cache 禁用、L1 页表清零
arch/arm/src/armv7-a/arm_head.S:285-321 .text 区域 L1 段映射 (1MB section entries)
arch/arm/src/armv7-a/arm_head.S:377-389 TLB/分支预测器/I-Cache 无效化
arch/arm/src/armv7-a/arm_head.S:407-559 TTBR0/TTBCR/DACR/SCTLR 配置,MMU 开启
arch/arm/src/armv7-a/arm_head.S:660-708 .Lvstart: 栈设置,arm_boot() 调用,跳转 nx_start
arch/arm/src/armv7-a/arm_head.S:719-755 arm_data_initialize: .bss 清零
arch/arm/src/qemu/chip.h:44-49 页表基址/大小宏定义
arch/arm/src/qemu/qemu_boot.c:77 arm_boot(): 平台 C 级初始化
arch/arm/src/qemu/qemu_memorymap.c:44-85 MMU 区域映射表
arch/arm/src/qemu/qemu_memorymap.h:43-63 QEMU virt 物理/虚拟内存布局
arch/arm/src/armv7-a/mmu.h:606-621 MMU 标志宏定义(MEMFLAGS, IOFLAGS)
boards/arm/qemu/qemu-armv7a/scripts/dramboot.ld:26-137 链接脚本,MEMORY 和 SECTIONS 布局
boards/arm/qemu/qemu-armv7a/src/qemu_boardinit.c:66-115 板级初始化函数 (qemu_board_initialize, board_late_initialize)
boards/arm/qemu/qemu-armv7a/src/qemu_bringup.c:161-211 板级 bringup(tmpfs/procfs 挂载,FDT 设备注册)
sched/init/nx_start.c:95-198 全局调度器数据结构 (g_readytorun, g_idletcb[], g_nx_initstate)
sched/init/nx_start.c:328-420 idle_task_initialize(): 为每个 CPU 创建 IDLE TCB
sched/init/nx_start.c:430-480 idle_group_initialize(): IDLE 组初始化
sched/init/nx_start.c:502-772 nx_start(): OS 全部初始化编排(完整函数)
sched/init/nx_bringup.c:297-391 nx_start_application(): 板级后期初始化 + 启动用户程序
sched/init/nx_bringup.c:436-456 nx_create_initthread(): 创建 AppBringUp 初始化线程
sched/init/nx_bringup.c:496-552 nx_bringup(): 系统 bring-up 完整函数
sched/task/task_start.c:68-108 nxtask_start(): 内核线程直接调用 vs 用户任务 up_task_start 分派
sched/task/task_spawn.c:46 task_spawn() 编译排除 (CONFIG_BUILD_KERNEL 下不可用)
binfmt/binfmt_exec.c:193 exec_spawn(): 用户进程创建入口
binfmt/binfmt_execmodule.c:152 exec_module(): ELF 加载 + 地址空间创建 + TCB 初始化
arch/arm/src/common/arm_initialize.c:62-121 up_initialize(): 硬件驱动初始化编排
arch/arm/src/common/arm_allocateheap.c:155-180 up_allocate_kheap(): 内核堆边界计算
arch/arm/src/qemu/qemu_irq.c:105 up_irqinitialize(): GIC 中断控制器初始化
arch/arm/src/qemu/qemu_timer.c:35 up_timer_initialize(): ARM 架构定时器初始化
arch/arm/src/qemu/qemu_serial.c:45-62 arm_earlyserialinit() / arm_serialinit(): PL011 串口
libs/libc/sched/task_startup.c:58 nxtask_startup(): 用户空间任务入口 (C++ 初始化 + main)
apps/system/nsh/nsh_main.c NSH shell 的 main 函数入口

10. 完整启动流程图

boot_flow