从零编写操作系统(C++):内核运行库、基础类型与编译链接实战——How to Make a Computer Operating System 第四章解析
从零编写操作系统(C++):内核运行库、基础类型与编译链接实战——How to Make a Computer Operating System 第四章解析
本文基于开源仓库 How-to-Make-a-Computer-Operating-System 的 Chapter-4 文档,深入讲解如何在 C++ 中搭建操作系统内核的"地基":C++ 内核运行时支持(构造函数、析构、异常与栈保护等 ABI 桩函数)、内核自带的 C 风格基础函数库(内存与字符串操作)、内核专用数据类型,以及一套不依赖宿主系统、面向 x86 的编译与链接流程。读完本文,你将能理解为什么裸机 C++ 内核需要手写运行时,并能独立编译链接出第一个
kernel.elf。
一、为什么 C++ 内核需要"自带的运行时"
内核可以用 C++ 编写,就像用 C 编写一样,但使用 C++ 会带来几类绕不开的"坑":运行时支持(runtime support)、构造函数(constructors)、析构函数(destructors)等。普通用户态程序由操作系统加载器配合 libc / libsupc++ 完成这些工作;而内核运行在裸机上,没有这些依赖,编译器却默认假设完整的 C++ 运行时可用——因此我们必须为内核补齐一套最小的运行时。
在本仓库中,这套运行时位于 src/kernel/runtime/,具体由以下文件组成(见 runtime/Makefile 的编译单元清单):
cxx.o—— C++ ABI 桩函数与new/delete运算符(cxx.cc);itoa.o—— 整数转字符串(itoa.cc);buffer.o—— 缓冲区工具(buffer.cc);memory.o——memset/memcpy等内存函数(memory.cc);string.o—— 字符串函数族(string.cc)。
# 摘自 src/kernel/runtime/Makefile
OBJS:= $(OBJS) runtime/cxx.o runtime/itoa.o runtime/buffer.o \
runtime/memory.o runtime/string.o
⚠️ 重要警告(原文档明确提示): 在虚拟内存与分页(paging)初始化完成之前,不能使用 new 和 delete 运算符。因为本内核中 operator new 最终调用的是内核内存分配器 kmalloc,而分页未就绪时内存分配语义不成立。从源码看(cxx.cc),new/delete 与 kmalloc/kfree 的绑定关系如下:
// 摘自 src/kernel/runtime/cxx.cc
void operator delete(void *ptr)
{
kfree(ptr); // 释放交给内核分配器
}
void* operator new(size_t len)
{
return (void*)kmalloc(len); // 分配交给内核分配器
}
void operator delete[](void *ptr) { ::operator delete(ptr); }
void* operator new[](size_t len) { return ::operator new(len); }
注意:kmalloc/kfree/ksbrk 的声明位于 src/kernel/runtime/alloc.h,这意味着编译内核的任何翻译单元都可安全使用 new/delete,只是运行时行为必须等到内存子系统(含分页)就绪。
二、C++ ABI 桩函数:补齐编译器需要的符号
不链接 libsupc++ 时,C++ 编译器(g++)在生成目标文件时会引用一系列 ABI 符号。若这些符号缺失,链接阶段会直接报未定义引用(undefined reference)。仓库在 cxx.cc 中以 extern "C" 声明并实现了这些桩函数:
// 摘自 src/kernel/runtime/cxx.cc
extern "C"
{
int __cxa_atexit(void (*Destructor) (void *), void *Parameter, void *HomeDSO);
void __cxa_finalize(void *);
void __cxa_pure_virtual();
void __stack_chk_guard_setup();
void __attribute__((noreturn)) __stack_chk_fail();
void _Unwind_Resume();
}
各符号的职责与仓库中的实现要点如下表:
| ABI 符号 | 编译器/运行时用途 | 本内核实现要点 |
|---|---|---|
__cxa_atexit |
注册进程退出时调用的析构函数 | 直接返回 0,表示注册成功但无实际操作 |
__cxa_finalize |
触发已注册的退出回调 | 空实现 |
__cxa_pure_virtual |
纯虚函数被调用时的处理 | 空实现(内核中应避免触发) |
_Unwind_Resume |
异常展开恢复点 | 空实现(配合 -fno-exceptions) |
__cxa_guard_acquire / __cxa_guard_release / __cxa_guard_abort |
函数内静态局部变量的线程安全初始化保护 | 仅用首字节作为 guard 标志,无锁实现 |
__stack_chk_guard_setup / __stack_chk_fail |
GCC 栈保护(Stack Smashing Protector) | 初始化 guard 值;失败时打印 Buffer Overflow (SSP Signal) 并死循环 |
其中静态局部变量初始化保护(guard)的实现值得注意——它位于 namespace __cxxabiv1 内,使用 __mode(__DI__) 定义 8 字节 __guard,但实际只操作首字节:
// 摘自 src/kernel/runtime/cxx.cc
namespace __cxxabiv1
{
__extension__ typedef int __guard __attribute__((mode(__DI__)));
extern "C"
{
int __cxa_guard_acquire(__guard *Guard) { return ! *(char *) (Guard); }
void __cxa_guard_release(__guard *Guard) { *(char *) Guard = 1; }
void __cxa_guard_abort(__guard *) { }
}
}
此外还有两个容易被忽略但链接必需的符号:
void *__dso_handle;—— 动态共享对象句柄,供__cxa_atexit使用,此处直接定义为一个全局指针;void *__gxx_personality_v0 = (void*)0xDEADBEAF;—— GCC 异常处理 personality 例程的地址,这里用一个哨兵值占位;__stack_chk_fail声明为__attribute__((noreturn)),实现中在打印错误后进入for(;;);死循环,符合 noreturn 语义。
三、内核版基础函数:内存与字符串操作(libc 子集)
内核代码不能使用标准库函数(无 libc 可链接),因此仓库提供了一组基础的内存与字符串函数,声明集中放在 src/kernel/runtime/libc.h 中:
// 摘自 src/kernel/runtime/libc.h
extern "C" {
void itoa(char *buf, unsigned long int n, int base);
void * memset(char *dst,char src, int n);
void * memcpy(char *dst, char *src, int n);
int strlen(char *s);
int strcmp(const char *dst, char *src);
int strcpy(char *dst,const char *src);
void strcat(void *dest,const void *src);
char * strncpy(char *destString, const char *sourceString,int maxLength);
int strncmp( const char* s1, const char* s2, int c );
}
3.1 字符串函数(string.cc)
实现要点如下:
strlen:线性扫描,遇'\0'前累加计数;strncpy:限制最大长度maxLength,实现中将其钳制到 255(超过 255 时按 255 处理),且若源字符串为 NULL 或结果超长会返回 NULL 表示失败;strcmp:逐字符比较直到遇 0,相等返回 0,否则返回 1;strcpy:逐个拷贝并在返回值中携带拷贝长度;strcat:利用strlen定位目标串末尾,再调用memcpy追加源串;strncmp:最多比较c个字符,返回首个差异字符的差值(*s1 - *s2)。
3.2 内存函数与整数转字符串
memset/memcpy实现在 memory.cc;itoa(buf, n, base)实现在 itoa.cc,支持按指定基数(如 10 或 16)将无符号长整数格式化写入缓冲区,是内核打印十六进制地址、调试输出的基础工具。
这些函数被内核其他模块广泛复用(例如 strcat 内部就调用了 memcpy 与 strlen),构成了内核打印、路径拼接、设备初始化等逻辑的最底层依赖。
四、C 风格内核类型定义(types.h)
后续代码将大量使用自定义类型。原文档强调:大多数变量类型使用无符号(unsigned),因为无符号类型的所有位都用于存值;而有符号类型会用第一个位(最高位)表示符号。仓库在 src/kernel/runtime/types.h 中定义了:
typedef unsigned char u8;
typedef unsigned short u16;
typedef unsigned int u32;
typedef unsigned long long u64;
typedef signed char s8;
typedef signed short s16;
typedef signed int s32;
typedef signed long long s64;
types.h 还顺带定义了其他内核基础设施:
- POSIX 风格类型:
size_t、pid_t、ino_t、off_t、dev_t、mode_t、uid_t、gid_t等; struct stat_fs(文件系统 stat 结构);- 统一返回码枚举:
RETURN_OK=0、NOT_DEFINED=-1、ERROR_MEMORY=-2、PARAM_NULL=-3、ERROR_PARAM=-4、RETURN_FAILURE=-128; - 中断处理函数指针类型
typedef void (*int_handler)(void);; - 常量
NULL 0、true 1、false 0。
这些类型是后续章节(内存管理、进程、文件系统)代码风格的基石。
五、编译与链接:打造一个"与系统无关"的内核
编译内核与编译 Linux 可执行程序完全不同:不能使用标准库,也不能对宿主系统有任何依赖。仓库通过 src/kernel/Makefile 定义了完整的编译链接流程,其顶层结构为:
# 摘自 src/kernel/Makefile
ARCH=x86
KERNEL=kernel.elf
SDKDIR=../sdk
INCDIR= -I ./ -I ./modules -I ./core -I ./arch/$(ARCH)
include ./arch/$(ARCH)/config.make
include ./runtime/Makefile
include ./core/Makefile
include ./modules/Makefile
include ./arch/$(ARCH)/Makefile
all: $(KERNEL)
$(KERNEL): $(OBJS)
$(LD) $(LDFLAG) -o $@ $^
cp $(KERNEL) $(SDKDIR)/bootdisk/
其中架构相关的编译器/链接器参数定义在 src/kernel/arch/x86/config.make(即原文档中展示的内容):
# Linker
LD=ld
LDFLAG= -melf_i386 -static -L ./ -T ./arch/$(ARCH)/linker.ld
# C++ compiler
SC=g++
FLAG= $(INCDIR) -g -O2 -w -trigraphs -fno-builtin -fno-exceptions -fno-stack-protector -O0 -m32 -fno-rtti -nostdlib -nodefaultlibs
# Assembly compiler
ASM=nasm
ASMFLAG=-f elf -o
5.1 编译参数逐项解读
| 参数 | 作用 |
|---|---|
-m32 / -melf_i386 |
生成 32 位 i386 代码 / ELF32 对象,与本仓库 x86 架构目标一致 |
-fno-exceptions |
禁用 C++ 异常,避免生成依赖异常展开的代码(配合空实现 _Unwind_Resume) |
-fno-rtti |
禁用运行时类型信息(RTTI),避免引入 typeinfo 相关运行时依赖 |
-fno-builtin |
不让编译器将 memset/memcpy 等调用内建替换为标准库实现 |
-fno-stack-protector |
关闭栈保护(尽管内核仍实现了 guard 桩函数以兼容链接) |
-nostdlib / -nodefaultlibs |
不链接任何标准库与默认库,强制内核零宿主依赖 |
-trigraphs |
支持三字符组(trigraph)转义,保证 C/C++ 源码兼容性 |
-static |
静态链接,杜绝动态链接器依赖 |
-T ./arch/$(ARCH)/linker.ld |
使用自定义链接脚本(见下) |
-g -O2 -O0 -w |
保留调试信息、控制优化级别、抑制警告(最终 -O0 生效) |
Makefile 还定义了三个编译规则,分别处理 .cc(g++)、.S(g++ 预处理汇编)与 .asm(nasm 汇编):
%.o: %.cc
$(SC) $(FLAG) -c $< -o $@
%.o: %.S
$(SC) $(FLAG) -c $< -o $@
%.o: %.asm
$(ASM) $(ASMFLAG) -c $< -o $@
并提供 help、tosdk、debug(nm -n)、hinfo(objdump -f)、dasm(反汇编)、run(制作磁盘镜像并启动 QEMU)、geniso、clean 等实用目标。
5.2 链接脚本:把内核放在 1MB 之上
链接过程由 src/kernel/arch/x86/linker.ld 控制,其关键点:
OUTPUT_FORMAT(elf32-i386)
OUTPUT_ARCH(i386)
ENTRY (_start)
SECTIONS{
. = 0x00100000; /* 内核基址:1MB 处(与 GRUB multiboot 约定一致) */
.text :{ *(.text) }
.data ALIGN (0x1000) : {
start_ctors = .; /* 记录构造函数段起止,供运行时调用 */
*(.ctor*)
end_ctors = .;
start_dtors = .;
*(.dtor*)
end_dtors = .;
*(.data)
}
.rodata ALIGN (0x1000) : { *(.rodata) }
.data ALIGN (0x1000) : { *(.data) }
.bss : {
sbss = .;
*(COMMON)
*(.bss)
ebss = .;
}
}
要点:
- 入口符号为
_start(由 start.asm 定义,为汇编启动代码); - 内核被链接到物理地址
0x00100000(1MB),这是 GRUB 加载 multiboot 内核的惯例位置; - 专门用
start_ctors/end_ctors标记.ctor*段,是为了让内核启动代码能够遍历并调用全局对象的构造函数(C++ 全局构造的裸机实现方式); .bss段用sbss/ebss标记,供启动代码清零未初始化数据区。
编译完成后,Makefile 会把 kernel.elf 复制到 src/sdk/bootdisk/,供启动盘镜像脚本 src/sdk/diskimage.sh 与 src/sdk/qemu.sh 制作启动镜像并在 QEMU 中运行。
六、使用与验证
在仓库根目录执行以下命令即可构建内核(需 g++、nasm、ld 等工具链):
cd src/kernel
make # 生成 kernel.elf 并复制到 sdk/bootdisk/
make run # 生成磁盘镜像并用 QEMU 启动
make dasm # 反汇编内核到 dasm.txt,便于对照调试
make clean # 清理目标文件
构建成功后可用 make debug(nm -n kernel.elf)查看符号表,确认 __cxa_atexit、_start、operator new 等运行时符号均已就位——这正是第四章"内核地基"是否搭好的最直接验证。
结语
第四章为整个操作系统搭建了三个层面的地基:C++ 运行时层(new/delete 与 ABI 桩函数,解决裸机 C++ 的链接与构造问题)、基础函数库层(内存/字符串/itoa,替代标准库)、类型与构建层(types.h 与面向 x86 的编译链接流程)。后续章节的内存管理、分页、进程与文件系统都将建立在这套地基之上。理解本章,你就掌握了"C++ 内核为何不能直接链接 libsupc++,以及如何自建运行时"这一核心工程问题的完整答案。