从零构建树莓派裸机操作系统: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用户则可以考虑使用WSL2(Windows 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核心:第一阶段引导与内核入口
树莓派的启动过程与传统的PC(BIOS -> Bootloader -> OS)截然不同,理解这个过程是成功的第一步。它的核心在于其SoC内部包含了一个名为VideoCore的GPU,这个GPU在启动时扮演了“第一引导程序”的角色。
3.1 树莓派的独特启动流程
当树莓派上电或复位时,最先运行的不是ARM CPU,而是GPU(VideoCore)中的一小段固化代码(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)*3=18。 #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: 优化级别。 # -mcpu=arm1176jzf-s: 指定树莓派1代/Zero的CPU型号。 # -mfpu=vfp: 指定浮点单元。 arm-none-eabi-gcc -c -nostdlib -ffreestanding -O2 -mcpu=arm1176jzf-s -mfpu=vfp -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 -l(Linux)或磁盘工具(macOS)找到SD卡对应的设备名,例如/dev/sdb(务必确认,切勿选错硬盘!)。 - 格式化SD卡第一个分区为FAT32。如果是一张新卡,你可以用以下命令(假设设备是
/dev/sdb):sudo 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函数里只做一件事:点亮LED(GPCLR0),然后直接进入死循环(while(1);)。如果还不亮,问题很可能在GPIO初始化或寄存器地址上。 - 利用未使用的外设:如果串口尚未初始化,调试信息无法输出,这是裸机开发初期最大的障碍。一个笨办法是:用GPIO控制多个LED,用不同的闪烁模式(摩尔斯电码)来代表程序执行到了哪个阶段,这是最原始的“printf调试法”。
当看到LED随着你的代码闪烁时,恭喜你,你已经完成了操作系统开发中最具里程碑意义的一步:让硬件在你的代码指挥下工作。这不仅仅是点亮一个灯,而是证明了你的工具链、启动流程、硬件操作全部正确,为后续所有更复杂的功能打下了坚实的基础。
5. 迈向交互:初始化UART实现串口打印
一个只会闪灯的内核是孤独的。我们需要给它“声音”,也就是通过串口(UART)与外界通信。树莓派的UART(PL011)是一个相对标准的外设,初始化后,我们就可以实现最简单的printf,将调试信息输出到电脑终端。
5.1 深入理解PL011 UART寄存器
UART的初始化流程遵循一个典型模式:配置波特率、数据格式、使能收发。我们需要操作以下几个关键寄存器(基地址为UART0_BASE = PERIPHERAL_BASE + 0x201000):
- UART_IBRD (Integer Baud Rate Divisor)和UART_FBRD (Fractional Baud Rate Divisor):共同决定波特率。计算公式为
波特率除数 = UARTCLK / (16 * 波特率)。其中UARTCLK是UART的输入时钟,树莓派上通常是3MHz(3000000 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 项目的终极魅力所在。