从0写OS内核-2-写一个最小的 linker.ld

这一步,我们要从"无形"变"有形":让你写出第一个可被链接器理解的"内存版图",并且用它生成一个最小的可执行文件。你将认识 linker.ld 的基本结构,明确入口、段与地址布局,建立起对"镜像形状"的直觉。更重要的是,完成本篇后,你的工程就不再是散落的源文件,而是一个可以被分析、被加载的实体。

增量目标

  • 写出可用的最小 linker.ld,把入口、段与地址布局明确下来

  • 用它链接一个最小目标文件,产出 kernel 可执行,并用工具检查段布局

  • 为后续的引导与打印留下稳定的基础

环境准备

  • 建议系统:WSL/Ubuntu 或任何 Linux 发行版

  • 安装依赖:sudo apt-get install build-essential nasm qemu-system-i386 grub-pc-bin xorriso mtools cmake

  • 目录约定:顶层目录使用 myos/

本篇新增文件

  • myos/linker.ld:链接脚本,定义入口与段布局

  • myos/stub.asm:一个最小的目标文件,用来验证链接是否成功(下一篇我们会替换为真正的引导代码)

目录结构(完成后)

  • myos/

  - linker.ld

  - stub.asm

完整代码


ENTRY(_start)

SECTIONS {

  . = 1M;

  .text ALIGN(4K) : { *(.multiboot) *(.text*) }

  .rodata ALIGN(4K) : { *(.rodata*) }

  .data ALIGN(4K) : { *(.data*) }

  .bss  ALIGN(4K) : { *(.bss*) *(COMMON) }

}


; myos/stub.asm

[BITS 32]

SECTION .text

global _start

_start:

  jmp $

构建与验证

  • 汇编:nasm -f elf32 -o build/stub.o myos/stub.asm

  • 链接:ld -nostdlib -T myos/linker.ld -o build/kernel build/stub.o

  • 查看布局:readelf -h build/kernel && readelf -S build/kernel

  • 观察:

  • 入口应为 _start

  • .text/.rodata/.data/.bss 段存在且按 4K 对齐

  • 虚拟地址起始约为 0x0010_0000(1MB)

为什么从 1MB 开始

  • 历史与兼容性:早期实模式与 BIOS 使用的低内存区域复杂而多变;从 1MB 开始能避开大部分兼容性问题

  • 后续分页与内核高半区设计更直观:你可以把内核放在一个"干净"的高地址起点,再逐步映射用户空间与设备内存

多引导头预留位(提前铺路)

  • 我们在 .text 段里预留 *(.multiboot),即将在下一篇加入多引导头,这让 GRUB 能识别你的镜像是一个"多引导内核"

  • 这种"先留位置再填内容"的策略能让你避免后续段重排带来的不必要麻烦

段与对齐

  • .text:代码与常量的执行主体,必须对齐到页边界以便分页映射与性能考虑

  • .rodata:只读数据,通常包含字符串与常量表

  • .data:可写的已初始化数据,反映程序的状态

  • .bss:未初始化数据,由链接器"占位",在运行时被清零

  • 4K 对齐是面向分页的默认策略;我们也可以根据需要调整到更大的粒度,但早期保持 4K 更通用

最小可执行并不等于可引导

  • 你现在得到的是一个"形状正确"的 ELF 可执行文件,但它还不能在 QEMU 或真机中直接引导

  • 引导涉及 BIOS/UEFI、引导加载器(例如 GRUB)、内核入口约定与协议(例如 Multiboot),我们将在下一篇逐步把入口与协议补齐

失败排查

  • 链接失败:检查 ld 命令中的 -T linker.ld 路径是否正确;确保 stub.asm 已经以 elf32 目标格式在 32 位模式下编译

  • 段缺失或未对齐:用 readelf -S build/kernel 检查每个段的对齐(AddrAlign)是否为 0x1000;若不是,检查 ALIGN(4K) 是否正确写入并被识别

  • 入口不是 _start:检查 ENTRY(_start) 是否在脚本第一行;确保 stub.asm 中定义了 global _start

可观测性从第一天开始

  • 即使现在我们还不打印任何字符,你仍旧可以"观察到"镜像形状:这就是可观测性的起点

  • 任何一步的改变,都应该通过工具验证其结果是否符合预期:readelf/objdump/file

深入理解:链接脚本的影响力

  • 它定义了你的内核"住在哪里",以及各个段之间如何排列;这对后续的分页、异常处理、栈与堆的规划都有深远影响

  • 入口位置决定了引导加载器把控制权交给谁;这在后续将直接关联到多引导规范、寄存器约定与堆栈初始化

  • 段的对齐关系会影响指令取址与数据访问的性能,以及分页表如何组织映射

进阶阅读建议

  • System V ABI 中对 ELF 格式的定义

  • GNU ld 链接脚本官方文档:段、符号、表达式与控制语句

  • Multiboot 规范:内核如何声明自己,以便引导加载器识别

下一步扩展(为第3篇预告)

  • .multiboot 段写入多引导头;在 .text 段提供一个真正的入口 _start

  • 用最小汇编代码把"Hello"写到 VGA 文本缓冲区,确认我们有能力"看见"

  • 准备把 CMakeLists.txt 引入工程,让构建与链接成为标准化流程

思考题(建议动手)

  • 如果把起始地址从 1MB 改到 2MB,会发生什么?你能用 readelf 观察到哪些变化?

  • 去掉 .multiboot 的预留,你后续要在链接脚本里做哪些额外修改来保证多引导头的位置正确?

  • 如果你未来想把 .text 放到更高的地址(例如 16MB),你会如何调整脚本并验证?

里程碑感知

  • 你已经把"形无"的源文件变成了"形有"的可执行,理解了入口、段与地址的基本概念

  • 接下来,我们会在这块地基上竖起第一面旗帜:一个属于你的 Hello,它将从屏幕左上角出现