macOS 内核之系统如何启动?

2019年10月28日 · 4 years ago

macOS 内核之系统如何启动?

前面几篇关于 XNU 内核学习的文章里,经常会提到有些数据来自启动时外部传入的参数,比如 mem_size。因为内核本身也是一个巨大的程序,它也会被编译成二进制,然后在系统启动的时候加载到内存里,提供给上层诸如多核 CPU 运算,虚拟内存,线程,进程等一系列能力。

那么问题来了,内核是在什么时候被加载到内存里的呢?谁来负责调用内核的入口函数呢?整个计算的启动过程是怎样的呢?

我在阅读了 Amit Singh 的《Mac OS X Internals》一书中跟启动相关的章节之后,想以此文总结记录一下。希望看到详细内容的读者朋友们,我个人非常推荐 Amit 这本书,内容深入浅出,通俗易读。

一、Firmware 和 Bootloader

我们知道系统内核也是一堆代码,XNU 内核就是 C 写的(I/O Kit 部分是 C++),最终会编译成一个二进制。在 macOS 上唯一能执行的二进制格式是 Mach-O。

全称是 Mach object file format,但是较真起来这个文件格式跟 Mach 内核没有半毛钱关系 XD。因为在 XNU 中,文件系统是由 BSD 实现的,Mach 并不识别任何文件系统。

在 macOS 操作的设计中,我们可以访问磁盘上的任何一个文件(当然有权限控制),所以我们也可以找到内核这个二进制,就是 /System/Library/Kernels/kernel。理论上你可以删掉这个文件,或者自己编译一个内核替换他,但是我不建议你这么做😂。

比 OS X 10.11 El Capitan 更早的系统直接就在 /mach_kernel

所以要让内核这个大程序跑起来,首先得有人把这个文件读取后放进内存里,找到入口,然后调用,这个过程大概是这样的:

1.1 开机,硬件启动。CPU(如果是多核则是主核)读取 ROM 上的 BIOS 并运行

ROM 即 Read Only Memory,在 PC 中通常是嵌在主板上的一块芯片。有自己折腾过 PC 攒机经验的小伙伴们肯定听说过 BIOS 这个东西。它的全称是 Basic Input/Output Service。CPU 从 ROM 中读取的就是 BIOS,在 Mac 上用的是 Intel 的 Extensible Firmware Interface(EFI) 接口,更老的 PowerPC CPU 则用的是 Open Firmware。

这个接口和硬件强相关,所以是由硬件厂商制定的标准。EFI 是英特尔制定的,目前已经交给 Unified EFI Forum 来维护,接口也改名为 UEFI。

因为这个东西并不是硬件 Hardware,也不是上层跑的软件 Software,所以取了个介乎中间的名字固件 Firmware。这东西是写在硬件上的,有些可以被擦写替换,有些则不可以。之前很火的利用 iOS Firmware 漏洞来越狱的工具非常强大的一点就在于此:这个固件写在硬件上,Apple 无法通过 OTA 让旧机器更新固件,也就无法修复漏洞,所以越狱对于旧机器会一直有效。

1.2 BIOS 加载会先做一系列硬件检查

这期间你甚至可以基于这个简单的系统开发软件,除了越狱之外还有很多可以做的。《Mac OS X Internals》提到 Open Firmware 还自带了 telnet, tftp 等工具,有点意思。

1.3 检查完之后就加载系统的 Bootloader

在 Mac 上以前用的是 BootX,后来 Apple 的所有产品,包括 iOS 都升级为 iBoot 了。这个东西~~也被编译为 Mach-O 文件~~是一个 efi 文件,可以参考这里。这文件就放在这里 /System/Library/CoreServices/boot.efi。代码是闭源的,之前有人放出了泄漏代码在 GitHub 上:https://github.com/h1x0rz3r0/iBoot。不过现在仓库被关闭了。

BootX 的代码是开源的,可以在这里找到: https://opensource.apple.com/tarballs/BootX/

BootX 负责初始化内核运行环境和加载内核,具体的分析可以看《Mac OS X Internals》的 4.10 章节。

1.4 最后 Bootloader 加载 kernel 到内存然后调用入口函数

前面已经讲过 kernel 是一个 Mach-O 文件,这个文件的结构大概是这样的:

mach_o_structure

1.4.1 otool 工具

开始加载内核之前,系统提供了 otool 这个工具用于分析 Mach-O 文件,这个有意思我们可以介绍一下。

# file 命令查看 kernel 的文件格式
➜  Kernels file kernel
kernel: Mach-O 64-bit executable x86_64

# otool 命令 -h 看一下 Mach Header 信息
➜  Kernels otool -hv kernel
Mach header
      magic cputype cpusubtype  caps    filetype ncmds sizeofcmds      flags
MH_MAGIC_64  X86_64        ALL  0x00     EXECUTE    18       3968   NOUNDEFS PIE

otool 代码是开源的,可以在这里找到。当我们运行 otool 命令时,会掉进它的 main() 函数,解析一大堆 -h 之类的 flag 之后,会调用内核的 open() 方法打开文件,位于 bsd/vfs/vsf_syscalls.c

1.4.2 Mach-O Header 信息

BSD 的 Mach-O 文件读取实现在这个函数:

int
open1(vfs_context_t ctx, struct nameidata *ndp, int uflags,
    struct vnode_attr *vap, fp_allocfn_t fp_zalloc, void *cra,
    int32_t *retval)

otool -h 取得的是 Mach Header 信息,结构体如下:

/*
 * The 64-bit mach header appears at the very beginning of object files for
 * 64-bit architectures.
 */
struct mach_header_64 {
    uint32_t    magic;      /* mach magic number identifier */
    cpu_type_t  cputype;    /* cpu specifier */
    cpu_subtype_t   cpusubtype; /* machine specifier */
    uint32_t    filetype;   /* type of file */
    uint32_t    ncmds;      /* number of load commands */
    uint32_t    sizeofcmds; /* the size of all the load commands */
    uint32_t    flags;      /* flags */
    uint32_t    reserved;   /* reserved */
};

/* Constant for the magic field of the mach_header_64 (64-bit architectures) */
#define MH_MAGIC_64 0xfeedfacf /* the 64-bit mach magic number */

MH_MAGIC_64MH_CIGAM_64 是不同大小端系统定义的常数,莫名有点喜感。

CPU Type 和 SubType 都在 XNU 代码里定义,位于 osfmk/mach/machine.h,一堆 hardcode 的定义。诸如 CPU Type CPU_TYPE_POWERPC64 或者 CPU_TYPE_x86_64 之类的,满满的历史痕迹。SubType 则是虽然大家都是 POWERPC 但也有可能不兼容,如果所有都兼容就是 CPU_SUBTYPE_POWERPC_ALL

filetype 定义在 EXTERNAL_HEADERS/mach-o/loader.hkernel 打出来是 2,也即是 MH_EXECUTE,可执行文件。

ncmds 是 load commands 有多少条, sizeofcmds 是所有 load commands 加起来的 size,以字节为单位。

详细的 Header 说明这里有篇文章大家可以参考一下: aidansteele/osx-abi-macho-file-format-reference: Mirror of OS X ABI Mach-O File Format Reference

1.4.3 Load Commands

Load command 就跟在 Mach Header 后面,应该算作 Header 的一部分,再往下就是编译好的二进制文件了。

Load Command 描述了文件的逻辑结构,以及文件在内存里的布局信息。内核执行 Mach-O 文件的实现在 bsd/kern/kern_exec.c,入口是 execve() 方法。在 parse_machfile() 方法中会遍历所有的 load commands 然后执行不同的命令,遇到 LC_MAIN 就会执行 load_main(),创建一个线程,加载函数主入口。

  1. execve()
  2. __mac_execve()
  3. exec_activate_image()
  4. exec_mach_imgact()
  5. load_machfile()
  6. parse_machfile()
  7. load_unixthread() // 赋值 entry point
  8. activate_exec_state()
  9. thread_setentrypoint() // entry_point 地址塞进 eip 寄存器(下一条指令)

Load command 是有很多不同类型的。以前 LC_THREAD 或者 LC_UNIXTHREAD 是函数入口,不过从 10.8 开始就改成 LC_MAIN 了。

现在我们用 otool -l 看看 kernel 的 load commands。

# otool 命令 -l 查看 load commands
➜  Kernels otool -l kernel
kernel:
Mach header
      magic cputype cpusubtype  caps    filetype ncmds sizeofcmds      flags
 0xfeedfacf 16777223          3  0x00           2    18       3968 0x00200001
Load command 0
      cmd LC_SEGMENT_64
  cmdsize 392
  segname __TEXT
   vmaddr 0xffffff8000200000
   vmsize 0x0000000000a00000
  fileoff 0
 filesize 10485760
  maxprot 0x00000005
 initprot 0x00000005
   nsects 4
    flags 0x0
...

otool -l 的结果非常长,可以 >> 到一个文本文件再打开。内核比较特殊,入口不在 LC_MAIN 而是 LC_UNIXTHREAD。我们找到 LC_UNIXTHREAD 所在的地方:

Load command 15
        cmd LC_UNIXTHREAD
    cmdsize 184
     flavor x86_THREAD_STATE64
      count x86_THREAD_STATE64_COUNT
   rax  0x0000000000000000 rbx 0x0000000000000000 rcx  0x0000000000000000
   rdx  0x0000000000000000 rdi 0x0000000000000000 rsi  0x0000000000000000
   rbp  0x0000000000000000 rsp 0x0000000000000000 r8   0x0000000000000000
    r9  0x0000000000000000 r10 0x0000000000000000 r11  0x0000000000000000
   r12  0x0000000000000000 r13 0x0000000000000000 r14  0x0000000000000000
   r15  0x0000000000000000 rip 0xffffff8000197000
rflags  0x0000000000000000 cs  0x0000000000000000 fs   0x0000000000000000
    gs  0x0000000000000000

其中 rip 寄存器里的地址 0xffffff8000197000 就是内核函数的入口。我们可以用 nm 工具列出内核的所有符号然后匹配一下:

➜  Kernels nm kernel | grep -i 197000
ffffff8000197000 S __start
ffffff8000197000 S _pstart

非常好,这样 XNU 内核就通过这个内存地址把 __start() 函数加载到内存里,愉快地开机了。

1.5 BootX 加载内核

看到这里不知道大家有没有个疑惑,就是 BSD 读取 Mach-O 的实现我懂,但是 BSD 不是在 kernel 里面的吗,这时候 kernel 自己都还没被加载啊喂😂。

没错,上面描述的是普通 Mach-O 文件被内核加载的过程,但是内核自己是被 Bootloader 加载的,所以它的实现是在 Bootloader 里面。新的 iBoot 没有开源所以我们看看 BootX 的实现。

BootX 的整体入口在 bootx.tproj/sl.subproj/main.c 文件中:

const unsigned long StartTVector[2] = {(unsigned long)Start, 0};

StartTVector 指向 Start() 函数:

static void Start(void *unused1, void *unused2, ClientInterfacePtr ciPtr)
{
  long newSP;

  // Move the Stack to a chunk of the BSS
  newSP = (long)gStackBaseAddr + sizeof(gStackBaseAddr) - 0x100;
  __asm__ volatile("mr r1, %0" : : "r" (newSP));

  Main(ciPtr);
}

调用 Main(),里面调用 InitEverything(),然后通过 GetBootPaths() 拿到 kernel 文件路径,然后 DecodeKernel() 获得内核的主入口内存地址:

gKernelEntryPoint = ppcThreadState->srr0;

最后 CallKernel() 调用内核入口:

// Call the Kernel's entry point
  (*(void (*)())gKernelEntryPoint)(gBootArgsAddr, kMacOSXSignature);

留意到这里内核的入口地址在 srr0 寄存器,这是老的 BootX 的代码,我们上面分析了一下 kernel 的 Mach-O 文件可以看到新的内核的入口是在 rip 寄存器上的。

1.6 为什么 nm 会输出一样地址的两个函数?

留意到我们刚用 nm 工具 grep 的时候有两个 start 函数:

➜  Kernels nm kernel | grep -i 197000
ffffff8000197000 S __start
ffffff8000197000 S _pstart

这是为啥?原因是这两个函数的实现可能是完全一致的,然后被编译优化了。那么这两个函数的实现是怎样的呢?

这两个函数是用汇编实现的,位置在 osfmk/x86_64/start.s。里面包含了 32 位和 64 位的兼容代码,比较长且我自己也看不懂😂。

.code32
    .text
    .section __HIB, __text
    .align  ALIGN
    .globl  EXT(_start)
    .globl  EXT(pstart)
LEXT(_start)
LEXT(pstart)

不过可以看到上述代码声明了全局符号 _startpstart 给链接器,并且 _startpstart 底下的实现是一样的。所以编译优化后这两个函数的地址是一样的。

那么为什么入口是 _start 呢?因为链接器默认的入口就是 _start。Linux 链接器 ld 的默认入口就是 _start,Apple 用的 Darwin Linker (ld64) 也是。可以到这里看看 Darwin Linker 的源代码: https://opensource.apple.com/source/ld64/ld64-97.2/

如果想要自定义入口可以使用 -e 参数:

ld -e my_entry_point -o out a.o

1.7 LC_MAINentryoff

Mac OS X 10.8 以及 iOS 10.6 以后,ld64 就把 LC_UNIXTHREAD 改成 LC_MAIN 了,同时整个系统所有 App 都实现了 ASLR(Address space layout randomization)

每次程序加载到内存的时候都会加上一个随机的偏移量,用于防止恶意程序的攻击。ASLR 是内核实现的,所以内核自身当然没法动态偏移。

我们用 otool -l 看看 TweetBot.app 的 Mach-O 文件。LC_MAIN 这个 cmd 不显示内存地址了,变成了 entryoff

Load command 11
       cmd LC_MAIN
   cmdsize 24
  entryoff 7084
 stacksize 0

但是符号表还在 Mach-O 文件中,存于 __LINKEDIT

entryoff 是入口函数相对于文件头的偏移量,16 进制为 0x1BAC

再加上一个不同平台不一样的基准偏移量,在 Mac 上是 0x100000000,所以是 0x100001BAC

方便起见,可以使用 MachOView 这个 App 打开 Mach-O 文件,但是 release App 一般都会去掉符号所以你也看不到这个地址对应的是不是 main 之类的函数。所以读者朋友可以自己编译一个 Debug 版来看,可参考 macOS 内核之一个 App 如何运行起来

一个 App 如何启动可以参考这里: macOS 内核之一个 App 如何运行起来

二、小结

其实 BIOS(UEFI) 启动时的硬件检查,Bootloader(BootX) 加载后做的事情,以及内核的主入口被调用之后,这一系列的操作都做了无数的事情。《Mac OS X Internals》书里对这些详细的步骤做了很好的解释,读起来对作者非常服气。

最近读内核代码总会发现各种曾经似懂非懂的概念在阻碍我继续学习,并且东看一下西看一下也不能形成很好的整体印象。所以阅读《Mac OS X Internals》这样的书是一种非常好的辅助。同时也建议读者朋友们不要只是读书,或者只是读代码。最好是两者结合动手实践一下,可以获得更深刻的理解。

内核系列文章

参考资料