从零构建树莓派裸机操作系统:Baking-Pi实战指南 1. 从零开始为什么选择“裸板”开发操作系统如果你对计算机底层感兴趣或者对操作系统如何从无到有启动感到好奇那么“裸板开发”可能是你技术生涯中最值得投入时间的一次冒险。所谓“裸板”就是指一块没有任何预装软件、没有引导程序、甚至没有操作系统的“裸”硬件板卡。我们面对的就是一块通电后只会执行固化在ROM里最基础指令的物理机器。Baking-Pi这个项目正是以树莓派Raspberry Pi为硬件平台引导我们一步步从零开始在“裸板”上构建一个属于自己的、哪怕是极其微小的操作系统内核。这听起来像是一个只有顶尖黑客才会涉足的领域但实际上它的门槛并没有想象中那么高不可攀。选择裸板开发尤其是从树莓派入手有几个非常实在的理由。首先树莓派的硬件文档相对开放和完整其Broadcom BCM2835/6/7系列SoC的官方手册虽然庞大但关键部分如ARM CPU核心、邮箱通信、GPIO、定时器的描述足够我们启动一个内核。其次树莓派社区庞大有无数先驱者踩过坑、分享过经验Baking-Pi教程本身就是剑桥大学计算机实验室的经典之作为我们铺平了道路。最后从裸板开始你能获得对计算机系统最彻底的理解——你写的每一行代码都是在直接与硬件对话没有操作系统这个“中间商”赚取抽象层的差价。很多人学习操作系统是从阅读Linux 0.11或xv6源码开始这固然是理解成熟OS设计思想的绝佳途径。但阅读和理解一个庞然大物与亲手从一片空白中构建一个能点亮LED灯的最小系统体验是截然不同的。前者让你知其然后者让你知其所以然。当你通过自己的代码让CPU跳出ROM的束缚初始化基础硬件最终在屏幕上打印出“Hello World”时那种对计算机掌控感的提升是任何理论课程都无法给予的。这不仅仅是学习操作系统更是一次深刻的硬件编程和系统启动流程的实战洗礼。2. 环境搭建交叉编译工具链与硬件准备在开始向SD卡写入二进制镜像之前我们必须先搭建一个可靠的开发环境。由于我们的目标平台ARM架构的树莓派与通常的开发主机x86架构的PC不同我们无法直接在PC上编译出树莓派能执行的程序。这就需要用到“交叉编译工具链”。2.1 交叉编译工具链的选型与安装交叉编译工具链是一套运行在宿主机Host如你的Linux PC上但生成目标机Target如树莓派可执行代码的编译器、链接器和相关库的集合。对于ARM架构最主流的选择是arm-none-eabi-gcc。这里的“none”表示没有操作系统裸机“eabi”指嵌入式应用二进制接口。它生成的代码不依赖任何操作系统库直接从复位向量开始执行完美契合我们的需求。在Ubuntu或Debian系的Linux系统上安装非常简单sudo apt update sudo apt install gcc-arm-none-eabi安装完成后可以通过arm-none-eabi-gcc --version来验证。对于macOS用户可以通过Homebrew安装brew install arm-none-eabi-gcc。Windows用户则可以考虑使用WSL2Windows Subsystem for Linux来获得一个完整的Linux环境或者使用预编译好的工具链包。这里有一个关键细节确保你安装的arm-none-eabi-gcc版本不要太旧。一些较老的教程可能使用arm-linux-gnueabi之类的工具链那是为运行Linux的ARM系统准备的会链接glibc等系统库不适合裸机开发。使用错误的工具链会导致链接时出现一堆未定义错误让你在起步阶段就陷入困境。2.2 硬件清单与连接你需要准备以下硬件树莓派板卡一代Model B或Zero即可它们使用BCM2835文档和社区支持最成熟。新版树莓派3B/4/5的启动流程更复杂涉及GPU固件不适合纯新手入门。Micro SD卡容量无需太大4GB或8GB足够Class 10以上确保读写速度。SD卡读卡器用于将编译好的内核镜像写入SD卡。串口转USB线强烈推荐这是裸板开发的“眼睛”和“嘴巴”。树莓派提供了UART串口GPIO 14/15通过一根USB转TTL串口线如CP2102、CH340芯片的连接到电脑我们就可以在终端上看到内核的打印输出并进行交互。没有它你的内核将运行在一片寂静中调试将异常困难。电源标准的5V Micro USB电源。网线可选用于后续实现网络功能初期非必需。连接方式将串口线的RX接树莓派GPIO 15 (TX) TX接GPIO 14 (RX) GND接GND。切记不要接VCC否则可能烧毁板卡或串口线。然后通过USB将串口线连接至电脑。2.3 串口终端配置在Linux上使用minicom或screen连接串口sudo minicom -D /dev/ttyUSB0 -b 115200在Windows上可以使用Putty或SecureCRT选择对应的COM口设备管理器中查看波特率设置为115200数据位8停止位1无奇偶校验无流控。正确连接后给树莓派上电如果此时SD卡是空的你可能会在串口终端看到一些BCM2835内部BootROM输出的调试信息或者一片空白。这很正常说明硬件连接和串口通信基本正常就等我们的内核登场了。3. Baking-Pi核心第一阶段引导与内核入口树莓派的启动过程与传统的PCBIOS - Bootloader - OS截然不同理解这个过程是成功的第一步。它的核心在于其SoC内部包含了一个名为VideoCore的GPU这个GPU在启动时扮演了“第一引导程序”的角色。3.1 树莓派的独特启动流程当树莓派上电或复位时最先运行的不是ARM CPU而是GPUVideoCore中的一小段固化代码BootROM。这段代码会依次检查SD卡、USB等设备寻找一个名为bootcode.bin的文件。这个文件是第二阶段的引导程序由Broadcom提供它的职责是初始化SDRAM等更复杂的硬件然后加载并启动GPU侧的固件start.elf。start.elf是GPU的固件它进一步初始化硬件并最终读取一个名为kernel.img默认名称的文件将其加载到内存的0x8000地址处然后才将控制权交给ARM CPU从0x8000开始执行。这就是我们裸机内核的入口点。因此我们的任务变得非常明确编写代码编译成一个名为kernel.img的二进制文件并把它和必要的引导文件bootcode.bin,start.elf通常从官方GitHub仓库获取一起放到SD卡的第一分区FAT32格式的根目录下。我们的内核代码的第一条指令就必须位于这个二进制文件的开头因为它会被原封不动地加载到0x8000。3.2 编写第一个“内核”点亮ACT LED让我们从一个最简单的目标开始让树莓派板上的ACT LED绿色指示灯闪烁。这个LED通过GPIO 16控制。首先我们需要用汇编语言编写启动代码start.S。为什么是汇编因为C语言运行时环境如栈、全局变量初始化还没有建立我们必须用最底层的指令设置好CPU状态。.section .init .global _start _start: // 1. 设置栈指针(SP)。栈是C函数调用所必需的。 // 树莓派1代有128MB内存我们选择一个较高的地址作为栈底。 ldr sp, 0x8000 // 2. 跳转到C语言的main函数 bl main // 3. 如果main函数意外返回则进入死循环 halt: b halt这段代码做了两件事设置栈指针然后跳转到C语言的main函数。栈指针指向0x8000我们的内核加载地址下方避免覆盖我们的代码。接着用C语言编写main.c操作GPIO来控制LED// GPIO寄存器基地址。对于BCM2835外设Peripheral的物理基地址是0x20000000。 #define PERIPHERAL_BASE 0x20000000 #define GPIO_BASE (PERIPHERAL_BASE 0x200000) // GPIO功能选择寄存器每个GPIO pin由3个bit控制10个pin一组一个寄存器。 // GPFSEL1控制GPIO 10-19。GPIO 16是第16个属于GPFSEL1位偏移是(16-10)*318。 #define GPFSEL1 (volatile unsigned int*)(GPIO_BASE 0x04) // GPIO输出置位寄存器写1使对应pin输出高电平。 #define GPSET0 (volatile unsigned int*)(GPIO_BASE 0x1C) // GPIO输出清除寄存器写1使对应pin输出低电平。 #define GPCLR0 (volatile unsigned int*)(GPIO_BASE 0x28) void delay(unsigned int ticks) { for(volatile unsigned int i 0; i ticks; i) { // 简单的忙等待延迟不精确但够用。 asm volatile(nop); } } int main(void) { // 1. 设置GPIO 16为输出模式 // 先清空GPIO 16对应的位域bit 18-20然后设置为001输出模式。 *GPFSEL1 ~(7 18); // 清空 *GPFSEL1 | (1 18); // 设置为输出 while(1) { // 2. 点亮LED (GPIO 16输出低电平因为LED另一端接3.3V) *GPCLR0 (1 16); delay(500000); // 3. 熄灭LED *GPSET0 (1 16); delay(500000); } return 0; // 理论上不会执行到这里 }这里有几个关键点内存映射I/O (MMIO)在ARM体系中控制硬件如GPIO是通过读写特定内存地址实现的。这些地址是物理地址。volatile关键字告诉编译器不要优化对这些地址的读写因为其值可能被硬件改变。GPIO编号与寄存器偏移树莓派的GPIO寄存器布局需要仔细查阅手册。GPIO 16属于第1组功能选择寄存器GPFSEL1位偏移计算是基本功。ACT LED的连接树莓派上的ACT LED通常是低电平点亮共阳极接法所以GPCLR0是点亮GPSET0是熄灭。这一点务必确认否则现象是反的。3.3 链接脚本告诉链接器内存布局我们需要一个链接脚本linker.ld它定义了最终内核镜像的内存布局最重要的是指定入口点为_start并且将代码的起始地址设置为0x8000。ENTRY(_start) SECTIONS { . 0x8000; /* 加载地址和运行地址相同 */ .text : { *(.init) *(.text*) } .data : { *(.data*) } .bss : { *(.bss*) } }这个脚本告诉链接器从地址0x8000开始放置代码段.text其中最先放置start.S中的.init段即我们的_start函数然后是其他所有.text段。接着是已初始化的全局变量.data和未初始化的全局变量.bss。对于简单的内核.data和.bss的搬运从加载地址到运行地址可以暂时忽略因为我们的加载地址就是运行地址。4. 构建、烧录与调试让内核跑起来环境与代码就绪后下一步就是将它们变成SD卡上可启动的kernel.img。4.1 编译与链接使用我们安装的交叉编译工具链进行编译和链接# 1. 编译汇编文件 arm-none-eabi-as -o start.o start.S # 2. 编译C文件指定裸机参数 # -nostdlib: 不使用标准库。 # -ffreestanding: 指示编译器程序运行在独立环境不依赖操作系统。 # -O2: 优化级别。 # -mcpuarm1176jzf-s: 指定树莓派1代/Zero的CPU型号。 # -mfpuvfp: 指定浮点单元。 arm-none-eabi-gcc -c -nostdlib -ffreestanding -O2 -mcpuarm1176jzf-s -mfpuvfp -o main.o main.c # 3. 链接目标文件使用链接脚本 arm-none-eabi-ld -T linker.ld -o kernel.elf start.o main.o # 4. 从ELF格式提取纯二进制镜像 arm-none-eabi-objcopy kernel.elf -O binary kernel.img现在当前目录下应该生成了kernel.img文件。你可以用arm-none-eabi-objdump -d kernel.elf来反汇编检查代码是否被正确放置在了0x8000附近。4.2 准备SD卡并烧录将SD卡插入读卡器并连接到电脑。使用fdisk -lLinux或磁盘工具macOS找到SD卡对应的设备名例如/dev/sdb务必确认切勿选错硬盘。格式化SD卡第一个分区为FAT32。如果是一张新卡你可以用以下命令假设设备是/dev/sdbsudo fdisk /dev/sdb # 在fdisk交互界面中输入 o 创建新的DOS分区表输入 n 创建新分区全部默认输入 t 更改分区类型为 c (W95 FAT32 LBA)输入 w 写入并退出。 sudo mkfs.vfat -F 32 /dev/sdb1挂载这个分区并复制必要的文件sudo mount /dev/sdb1 /mnt # 从树莓派官方固件仓库下载 bootcode.bin 和 start.elf或者从任意一个树莓派官方系统镜像中提取。 sudo cp bootcode.bin start.elf kernel.img /mnt/ sudo umount /mnt注意bootcode.bin和start.elf必须与你的树莓派型号匹配。对于Pi 1/Zero使用最基本的版本即可。可以从GitHub的raspberrypi/firmware仓库的boot目录下获取。4.3 上电调试与常见问题安全弹出SD卡将其插入树莓派连接好串口线打开串口终端最后上电。理想情况你应该在终端里看到……什么都没有。因为我们的内核还没有实现任何串口输出功能。但是树莓派板上的绿色ACT LED应该开始规律地闪烁这说明你的内核已经成功运行CPU正在执行你的main函数里的循环。如果LED不亮请按以下步骤排查检查电源确保电源适配器能提供足够的电流5V 2A以上电源灯红色常亮。检查SD卡确认bootcode.bin,start.elf,kernel.img三个文件都在FAT32分区的根目录并且文件名完全正确大小写敏感。可以尝试换一张SD卡。检查编译输出用objdump反汇编kernel.elf确认_start和main函数的地址确实在0x8000之后。确认链接脚本正确指定了入口点。检查GPIO配置确认你操作的GPIO pin号是正确的Model B是GPIO 16但有些版本可能是GPIO 47需查证。确认输出电平逻辑是否正确点亮是置低还是置高。简化代码移除所有复杂逻辑在main函数里只做一件事点亮LEDGPCLR0然后直接进入死循环(while(1);)。如果还不亮问题很可能在GPIO初始化或寄存器地址上。利用未使用的外设如果串口尚未初始化调试信息无法输出这是裸机开发初期最大的障碍。一个笨办法是用GPIO控制多个LED用不同的闪烁模式摩尔斯电码来代表程序执行到了哪个阶段这是最原始的“printf调试法”。当看到LED随着你的代码闪烁时恭喜你你已经完成了操作系统开发中最具里程碑意义的一步让硬件在你的代码指挥下工作。这不仅仅是点亮一个灯而是证明了你的工具链、启动流程、硬件操作全部正确为后续所有更复杂的功能打下了坚实的基础。5. 迈向交互初始化UART实现串口打印一个只会闪灯的内核是孤独的。我们需要给它“声音”也就是通过串口UART与外界通信。树莓派的UARTPL011是一个相对标准的外设初始化后我们就可以实现最简单的printf将调试信息输出到电脑终端。5.1 深入理解PL011 UART寄存器UART的初始化流程遵循一个典型模式配置波特率、数据格式、使能收发。我们需要操作以下几个关键寄存器基地址为UART0_BASE PERIPHERAL_BASE 0x201000UART_IBRD (Integer Baud Rate Divisor)和UART_FBRD (Fractional Baud Rate Divisor)共同决定波特率。计算公式为波特率除数 UARTCLK / (16 * 波特率)。其中UARTCLK是UART的输入时钟树莓派上通常是3MHz3000000 Hz。对于115200波特率除数 3000000 / (16 * 115200) ≈ 1.627。整数部分(IBRD)为1小数部分(FBRD) 0.627 * 64 ≈ 40。UART_LCRH (Line Control Register)设置数据位8位、停止位1位、奇偶校验无等。UART_CR (Control Register)总控制寄存器用于使能UART功能、使能发送和接收。UART_FR (Flag Register)状态寄存器我们主要关心TXFF发送FIFO满和RXFE接收FIFO空位用于轮询方式发送和接收字符。UART_IMSC (Interrupt Mask Set/Clear Register)中断掩码寄存器初期我们使用轮询暂时不配置中断。5.2 实现putc和getc我们先实现最底层的字符发送和接收函数#define UART0_BASE (PERIPHERAL_BASE 0x201000) typedef volatile unsigned int reg32; reg32* uart_reg(int offset) { return (reg32*)(UART0_BASE offset); } void uart_init(void) { // 1. 暂时禁用UART配置期间保持安静 *uart_reg(UART_CR_OFFSET) 0; // 2. 设置波特率为115200 // UARTCLK 3MHz unsigned int baud_rate_divisor 3000000 / (16 * 115200); // 约1.627 *uart_reg(UART_IBRD_OFFSET) baud_rate_divisor; *uart_reg(UART_FBRD_OFFSET) (unsigned int)((baud_rate_divisor - (unsigned int)baud_rate_divisor) * 64 0.5); // 四舍五入 // 3. 设置数据格式: 8位数据1位停止位无奇偶校验无FIFO *uart_reg(UART_LCRH_OFFSET) (0x3 5); // 8位数据位 // 也可以写成: UART_LCRH_WLEN_8BIT | UART_LCRH_FEN (如果使能FIFO) // 4. 使能UART使能发送和接收 *uart_reg(UART_CR_OFFSET) UART_CR_UARTEN | UART_CR_TXE | UART_CR_RXE; } void uart_putc(char c) { // 等待发送FIFO有空位 while (*uart_reg(UART_FR_OFFSET) UART_FR_TXFF) { // 忙等待 } // 写入数据寄存器发送字符 *uart_reg(UART_DR_OFFSET) c; } char uart_getc(void) { // 等待接收FIFO有数据 while (*uart_reg(UART_FR_OFFSET) UART_FR_RXFE) { // 忙等待 } // 读取数据寄存器 return *uart_reg(UART_DR_OFFSET); }将uart_init()调用添加到main函数的开头然后在循环中调用uart_putc(A)重新编译烧录。此时打开串口终端你应该能看到源源不断的字符‘A’输出。这是一个激动人心的时刻你的内核终于能“说话”了5.3 实现简易的printf有了uart_putc我们可以实现一个极简版的printf用于输出格式化的字符串。这里我们实现一个只支持%s和%d的版本void uart_puts(const char* str) { while (*str) { if (*str \n) { uart_putc(\r); // 换行时补回车这是串口终端的惯例 } uart_putc(*str); } } static void print_num(unsigned int num, int base) { char buf[32]; char* p buf sizeof(buf) - 1; *p \0; do { *--p 0123456789abcdef[num % base]; num / base; } while (num ! 0); uart_puts(p); } void printf(const char* fmt, ...) { // 极简实现省略了va_list等标准库内容直接假设最多一个参数 // 在实际项目中你需要实现完整的可变参数处理或者使用更简单的设计。 uart_puts(fmt); // 简化处理直接输出字符串 // 更完整的实现需要解析fmt处理%等格式符这里从略。 }虽然这个printf很简陋但足以输出“Hello, Baking-Pi!”这样的字符串了。将uart_puts(Hello, Baking-Pi!\n);加入main函数重新编译烧录你将在串口终端看到这行问候。至此你的内核具备了最基本的输入输出能力调试将变得无比轻松。6. 中断与异常处理让内核响应外部事件一个只能轮询不断检查状态的内核是低效的。真正的操作系统需要能够响应“事件”比如定时器到时、键盘输入、网络数据到达。这依赖于CPU的中断机制。ARM处理器通过异常向量表来处理中断和各类异常如复位、未定义指令、数据中止等。6.1 设置ARM异常向量表ARM的异常向量表是一段位于特定内存地址通常是0x00000000或0xffff0000的代码。每个异常类型占用4字节里面存放一条跳转指令。当异常发生时CPU会自动跳转到对应的地址执行。对于裸机开发我们通常将向量表放在0x00000000。但树莓派的物理内存从0x00000000开始而我们的内核加载在0x8000。因此我们需要在链接脚本中指定向量表的地址或者在启动代码中动态安装向量表。一个更常见的做法是利用树莓派启动后CPU处于高地址模式High Exception Vectors地址0xffff0000的特性。但为了简化我们可以直接在0x00000000处放置向量表。这需要我们确保链接脚本将向量表代码放在镜像的最开始。修改start.S在最前面加入向量表.section .vectors, ax .global _vectors _vectors: ldr pc, reset_addr // 复位 ldr pc, undef_addr // 未定义指令 ldr pc, swi_addr // 软件中断(SWI) ldr pc, prefetch_addr // 预取中止 ldr pc, data_abort_addr // 数据中止 nop // 保留 ldr pc, irq_addr // IRQ中断 ldr pc, fiq_addr // FIQ中断 reset_addr: .word _start undef_addr: .word undef_handler swi_addr: .word swi_handler prefetch_addr: .word prefetch_abort_handler data_abort_addr:.word data_abort_handler irq_addr: .word irq_handler fiq_addr: .word fiq_handler同时修改链接脚本确保.vectors段被放置在0x00000000或者0x8000如果我们希望向量表紧挨着内核。更可行的方案是将整个内核的加载地址改为0x00000000但这样会与GPU固件等有冲突。一个折中的实践是在_start处用代码将向量表拷贝到0x00000000。这里我们采用另一种常见方法利用链接器将向量表链接到0x00000000但实际加载地址还是0x8000在_start中手动重定位。6.2 初始化中断控制器与定时器树莓派的中断控制器Interrupt Controller相对复杂它管理着数十个中断源如系统定时器、GPIO、USB等。我们需要使能核心的中断响应通过设置ARM CPU的CPSR寄存器清除I位IRQ中断禁止位和F位FIQ中断禁止位。配置中断控制器使能我们关心的中断源例如系统定时器中断。实现中断处理函数在irq_handler中读取中断控制器的基础中断待处理寄存器IRQ basic pending判断是哪个中断源调用对应的处理例程最后清除中断源。以系统定时器System Timer为例它是一个非常稳定的中断源可以用来实现任务调度或精确定时。定时器相关寄存器位于0x20003000附近。通过设置比较寄存器Cx的值当系统计时器CLO达到该值时就会产生中断。中断处理函数irq_handler的框架如下用汇编编写保存上下文后跳转到C函数irq_handler: // 1. 保存所有可能被破坏的寄存器到栈 (r0-r12, lr, spsr) sub lr, lr, #4 // 计算返回地址 stmfd sp!, {r0-r12, lr} // 保存寄存器组 // 2. 调用C语言的中断分发器 bl c_irq_handler // 3. 恢复寄存器并返回 ldmfd sp!, {r0-r12, pc}^ // ^ 表示同时恢复CPSR在c_irq_handler函数中我们读取中断控制器状态判断如果是定时器中断则执行相应的操作例如递增一个全局的jiffies计数器然后清除定时器的中断位。6.3 从轮询到中断驱动的UART之前我们的uart_getc是轮询的会一直占用CPU。现在我们可以将其改为中断驱动。配置UART的接收中断当有字符到达时CPU自动跳转到中断处理程序我们将字符读入一个环形缓冲区FIFO。然后上层代码可以从这个缓冲区中非阻塞地读取字符。这需要在uart_init中使能UART接收中断设置UART_IMSC寄存器。在中断分发器c_irq_handler中判断是否为UART中断如果是则进一步判断是接收中断还是发送中断。对于接收中断读取UART_DR寄存器将字符放入环形缓冲区。提供一个uart_read(char *buf, int len)函数从环形缓冲区中拷贝数据。实现中断驱动的输入输出是内核从“玩具”迈向“实用”的关键一步。它释放了CPU让内核可以在等待IO时执行其他任务尽管目前我们还只有一个任务为后续的多任务调度奠定了基础。7. 内存管理与任务调度雏形一个完整的操作系统核心是管理资源其中最重要的两项是内存和CPU时间。虽然我们的Baking-Pi内核还很微小但可以开始构思这些核心机制的雏形。7.1 实现简单的堆内存分配目前我们的所有变量都是静态或栈上的。要实现动态数据结构如任务控制块链表就需要堆内存分配。我们可以实现一个极其简单的内存分配器例如基于空闲链表free list的分配。首先我们需要知道哪些内存是可用的。从链接脚本的末尾通过定义一个符号_end到物理内存的末尾树莓派1代是0x20000000即512MB边界但实际可用内存少于这个值需参考ATAGS或通过Mailbox查询这片区域可以作为我们的堆。一个最简单的分配器实现思路在_end处初始化一个大的内存块作为堆的起点。分配时遍历空闲链表找到第一个大小足够的内存块将其分裂如果足够大并返回分配部分的地址。释放时将内存块放回空闲链表并尝试与相邻的空闲块合并防止碎片化。对于教学内核甚至可以更简单实现一个“水线”watermark分配器只维护一个指针指向下一个可分配地址分配时直接移动指针永不释放。这虽然会造成内存泄漏但对于初期实验足够了。我们可以先实现这个extern unsigned char _end; // 来自链接脚本内核结束地址 static unsigned char* heap_ptr _end; void* simple_malloc(unsigned int size) { // 简单对齐 size (size 7) ~7; unsigned char* ptr heap_ptr; heap_ptr size; // 这里应该检查是否超出可用内存范围暂时省略 return (void*)ptr; }7.2 设计最简化的任务控制块TCB为了管理多个“任务”或线程我们需要为每个任务定义一个任务控制块TCB保存其上下文寄存器值、栈指针、状态等。typedef struct task_control_block { unsigned int sp; // 任务栈指针 unsigned int pc; // 程序计数器任务入口点 unsigned int status; // 任务状态就绪、运行、阻塞等 struct task_control_block *next; // 链表指针 // 可以加入更多信息如任务ID、优先级等 } tcb_t;每个任务需要有自己独立的栈空间。我们可以在创建任务时用simple_malloc分配一块内存作为栈并精心布置栈上的初始上下文使其看起来像是刚刚被中断了一样。当调度器切换到该任务时只需恢复其栈指针sp和程序计数器pc即可。7.3 协作式调度与上下文切换最初的调度可以是协作式的Cooperative Multitasking。每个任务运行一段时间后主动调用yield()函数让出CPU。yield()的核心是上下文切换保存当前任务的上下文所有通用寄存器、状态寄存器到其TCB中。从TCB链表中选择下一个就绪任务。恢复下一个任务的上下文从它的TCB中加载寄存器。使用一条特殊的汇编指令如mov pc, lr或直接加载pc跳转到新任务继续执行。上下文切换需要用纯汇编实现因为涉及直接操作栈指针和程序计数器。一个典型的yield汇编例程会保存r0-r12, lr, sp等寄存器到当前任务的栈然后更新TCB中的sp再切换到新任务的栈最后从新栈中恢复所有寄存器并返回。当我们将定时器中断与调度器结合在定时器中断处理函数中触发调度就实现了抢占式调度Preemptive Multitasking。这时内核就真正具备了现代操作系统的核心特征之一多任务并发。虽然我们的Baking-Pi可能只运行两个交替打印字符的任务但背后的机制与Linux或Windows的调度器在本质上是相通的。走到这一步你已经不再仅仅是一个裸机程序的编写者而是一个微型操作系统内核的设计者和实现者。你亲手搭建了计算机从冷启动到并发多任务的完整链条。这个过程充满挑战每一个功能的实现都需要查阅手册、调试硬件、思考软件设计但每一步的突破带来的成就感以及获得的底层系统知识是阅读任何教科书都无法比拟的。这就是裸板开发操作系统 Baking-Pi 项目的终极魅力所在。