从零编写操作系统(C++):内核运行库、基础类型与编译链接实战——How to Make a Computer Operating System 第四章解析

原创2026-09-30 16:00:241,884 阅读
文章标签:操作系统教程系统编程

从零编写操作系统(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++,以及如何自建运行时"这一核心工程问题的完整答案。

登录后查看全文
How-to-Make-a-Computer-Operating-System