macOS 内核之 hw.epoch 是个什么东西?

Oct 16, 2019 at 22:38:15

macOS 内核之 hw.epoch 是个什么东西?

今天在学习 macOS 系统的 sysctl() 函数时遇到了一个有意思的东西——EPOCH。遂写此文以记之。

我们知道 macOS(OS X) 系统中有一层核心系统(Core OS)叫做 Darwin。iOS, watchOS 等苹果自家硬件的许多系统都是 Darwin 做的上层开发。所以 iOS 和 macOS 都可以使用 darwin 提供的 sysctl() 函数来获取系统硬件信息,比如 CPU 信息,内存大小之类。

Diagram of Mac OS X architecture

根据 2006 年的这张系统架构图我们可以看到,Darwin 里面主要包含 System UtilitiesXNU 内核。XNU 即 X is Not Unix,最早由乔布斯离开苹果后创办的 NeXT 公司开发。XNU 是一个混合内核(hybrid kernel),包含两个部分。FreeBSD 提供了文件系统,网络接口,POSIX 接口等实现,Mach 内核则提供了 IOKit 等硬件驱动接口(在 NeXT 时期叫做 Driver Kit)。

一、sysctl() 函数

我们在 iOS/Mac App 里面经常需要获取用户设备信息用于 Debug 或是针对不同硬件的差异化设计。所以大家应该对 "hw.machine"sysctlbyname()这样的接口不陌生。

sysctl()接口是由 BSD 提供的,基本上所有 Unix-like 系统都有这个接口,同时也会提供一个跑在终端的命令。"hw.machine" 是其中一个 Key,通过它可以拿到设备信息。在 iPhone 上输出iPhone6,1这样的设备类型代码,Mac 上则是x86_64或者i386

在 macOS 上我们还可以通过终端运行以下命令:

sysctl -a

输出所有的 key-value 结果,也可以指定 sysctl -w key输出指定 key 的结果。

二、什么是 EPOCH

sysctl.h 头文件中定义了一堆 CTL_HW identifiers,也就是上面的 Key。我发现里面有一个叫做 HW_EPOCH 的 Key 不晓得是啥。

#define    HW_EPOCH    10      /* int: 0 for Legacy, else NewWorld */

看注释如果输出 0 就是老实现,其他就是新的。但是 EPOCH 是啥?

其实这个词目前最常用于指代 Unix 时间戳,也就是我们熟悉的 1970-01-01 00:00:00。Epoch 是在计算机里本意用于计时的基准,比一个 epoch 的时间小的记为负数,大于则记为正数。而目前最广泛使用的是 Unix 以 1970 这个时间为基准的计算法。

但是早期的计算机操作系统使用 32 位 Int 来存储这个时间戳,从 1970 开始计时,最长可以记到 2038-01-19 03:14:07,于是这个问题也被称为 2038 年问题,和著名的 2000 年问题(千年虫问题)是类似的。

那么解决问题的方法很简单,只要把负责存储时间的 time_t 由 32 位改为 64 位就可以了。现在所有的 iPhone, Mac 基本都是 64 位的,理论上不应该再有这个问题了。

但是我运行sysctl -w hw.epoch结果却是 0.

➜  darwin-xnu git:(master) sysctl -w hw.epoch
hw.epoch: 0

这就很费解了。

三、看看源码吧

既然注释信息量太少,那我们看看源码如何?好在 darwin-xnu 是开源的,我们 clone 下来看看 sysctl() 的实现。

这份内核的代码是用 C 语言所写,使用了大量的宏。以我对 darwin 那浅薄的理解,读起来非常费劲。比如说 sys/sysctl.h 文件里定义了以下函数:

int sysctl(int *, u_int, void *, size_t *, void *, size_t);

在不同的架构上(i386/arm/arm64)各有一个 sysctl.c 文件,但是全都没有 sysctl() 函数的实现。

通过阅读头文件和宏的定义,我大致能理解类似 SYSCTL_PROCSYSCTL_INT 是生成 oid 然后写入 mib。由此系统的 sysctl 就可以根据注册好的 key 来获取对应的硬件数据。我也在 kern_newsysctl.c 里找到了一个 sysctl() 函数的实现,但是它接受三个参数而不是上面定义的五个,而且格式也不一样:

int
sysctl(proc_t p, struct sysctl_args *uap, __unused int32_t *retval)

于是我在遍寻 sysctl 文档无果的情况下,想到不如看看 FreeBSD 的代码里面是否有这个函数的实现。还真就在 lib/libc/gen/sysctl.c 里找到一个完全符合的函数实现:

int
sysctl(const int *name, u_int namelen, void *oldp, size_t *oldlenp,
    const void *newp, size_t newlen)

该函数先调用 __sysctl() 看看是否能找到动态注册的 key-value,如果找得到并且不属于 CTL_USER 命名下的,就直接返回,否则用 switch-case 处理 CTL_USER 的值。

但是 __sysctl() 函数用了 extern 关键字修饰:

extern int __sysctl(const int *name, u_int namelen, void *oldp,
    size_t *oldlenp, const void *newp, size_t newlen);

并且我还是没有找到 __sysctl() 的具体实现,于是猜测可能是写进了宏里,拼接后注册到 mib (Management Infomation Base,简单理解为存储了一大堆叫做 oid 的键值对的文件格式即可)里面。

darwin-xnu 的 bsd/dev/i386/sysctl.c 里倒是有这样的定义:

static int
_i386_cpu_info SYSCTL_HANDLER_ARGS

#define SYSCTL_HANDLER_ARGS (struct sysctl_oid *oidp, void *arg1, int arg2,
struct sysctl_req *req)

但是却没有定义 _i386_cpu_info 是什么,所以我只能猜测是编译时针对不同的平台会把类似 _i386_cpu_info 这样的东西展开成别的东西。但是我没有证据,于是寻找 sysctl() 函数实现就无果了。

但是在 darwin-xnu 和 FreeBSD 两个项目中都有 kern_mib.c 文件。这倒是可以用来解释系统内核如何在初始化的时候把硬件信息存储起来以备查询。根据 FreeBSD 的这个文档,所有的 sysctl 信息都存储在一个 mib entry tree 中,每条信息就是一个 mib entry。一个 mib entry 就是

{
    int *id 
    size_t idlevel
}

其中 idlevel 是 1 到 SYSCTLMIF_MAXIDLEVEL 之间。在 darwin 的 bsd/kern/kern_mib.c 文件中,有这样一个定义:

SYSCTL_PROC(_hw, HW_EPOCH,        epoch, CTLTYPE_INT | CTLFLAG_RD | CTLFLAG_MASKED | CTLFLAG_LOCKED, 0, HW_EPOCH, sysctl_hw_generic, "I", "");

其中 SYSCTL_PROC 定义如下:

#define SYSCTL_PROC(parent, nbr, name, access, ptr, arg, handler, fmt, descr) \
    SYSCTL_OID(parent, nbr, name, access, \
        ptr, arg, handler, fmt, descr)

/* This constructs a "raw" MIB oid. */
#define SYSCTL_STRUCT_INIT(parent, nbr, name, kind, a1, a2, handler, fmt, descr)
{
&sysctl_##parent##_children, { 0 },
nbr, (int)(kind|CTLFLAG_OID2), a1, (int)(a2), #name, handler, fmt, descr, SYSCTL_OID_VERSION, 0
}

#define SYSCTL_OID(parent, nbr, name, kind, a1, a2, handler, fmt, descr)
struct sysctl_oid sysctl_##parent####name = SYSCTL_STRUCT_INIT(parent, nbr, name, kind, a1, a2, handler, fmt, descr);
SYSCTL_LINKER_SET_ENTRY(_sysctl_set, sysctl##parent##
##name)

最为关键的地方就是 SYSCTL_OID 这个宏,生成了一个 sysctl_oid 结构体:

struct sysctl_oid {
    struct sysctl_oid_list *oid_parent;
    SLIST_ENTRY(sysctl_oid) oid_link;
    int     oid_number;
    int     oid_kind;
    void        *oid_arg1;
    int     oid_arg2;
    const char  *oid_name;
    int         (*oid_handler) SYSCTL_HANDLER_ARGS;
    const char  *oid_fmt;
    const char  *oid_descr; /* offsetof() field / long description */
    int     oid_version;
    int     oid_refcnt;
};
参数 描述
parent key 里的父级结构,比如 hw.machine 里的 hw
nbr ID,基本上只要填 OID_AUTO 就行,会自动生成一个
name key 里的子项名,比如 hw.machine 里的 machine
kind/access CTLFLAG_, 有好几个可选。 CTLFLAG_ANYBODY | CTLFLAG_MASKED | CTLFLAG_LOCKED | CTLFLAG_KERN | CTLFLAG_WR
a1, a2 传给 handler 的参数
format string 告诉 sysctl 工具要如何显示数据。

创建好结构体之后,使用 SYSCTL_LINKER_SET_ENTRY 宏注册。这里的 linker set 技术是 darwin 独有的,FreeBSD 则是生成了 raw oid 之后使用 DATA_SET() 宏。

关于 linker set 技术,sysctl.h 的注释如下:

 * USE THIS instead of a hardwired number from the categories below
 * to get dynamically assigned sysctl entries using the linker-set
 * technology. This is the way nearly all new sysctl variables should
 * be implemented.
 *
 * e.g. SYSCTL_INT(_parent, OID_AUTO, name, CTLFLAG_RW, &variable, 0, "");
 * Note that linker set technology will automatically register all nodes
 * declared like this on kernel initialization, UNLESS they are defined
 * in I/O-Kit. In this case, you have to call sysctl_register_oid()
 * manually - just like in a KEXT.

也就是说,该文件里类似 SYSCTL_INT 定义的宏就会会在内核初始化的时候自动进行注册,I/O-Kit 里的除外,这种情况下可以用 sysctl_register_oid() 函数来主动注册。SYSCTL_PROCSYSCTL_INT 类似,只是定义的返回值不一样,后者返回 int 类型,前者则会调用自定义的 handler 函数来进行处理。而 HW_EPOCH 就是注册为了 SYSCTL_PROC

它的 handler 是 sysctl_hw_generic(),我们可以在 kern_mib.c 里找到它的实现:

static int
sysctl_hw_generic(__unused struct sysctl_oid *oidp, __unused void *arg1,
    int arg2, struct sysctl_req *req)

基本上一通 switch-case 找到 HW_EPOCH:

case HW_EPOCH:
        epochTemp = PEGetPlatformEpoch();
    if (epochTemp == -1)
        return(EINVAL);
    return(SYSCTL_RETURN(req, epochTemp));

但是非常遗憾,PEGetPlatformEpoch() 我只找到 IOKitIOPlatformExpert.cpp 的实现:

int PEGetPlatformEpoch(void)
{
    if( gIOPlatform)
    return( gIOPlatform->getBootROMType());
    else
    return( -1 );
}

long IOPlatformExpert::getBootROMType(void)
{
return _peBootROMType;
}

void IOPlatformExpert::setBootROMType(long peBootROMType)
{
_peBootROMType = peBootROMType;
}

kern_mib.c 里面引用了 #include <IOKit/IOPlatformExpert.h> 所以应该就是调用的这个函数。_peBootROMType 作为 IOPlatformExpert 类的私有成员,初始化默认值为随机数。也就是说,如果不调用 setBootROMType() 那么它就不是 0。但是我搜索了一下没有地方用到 setBootROMType(),那只能说这个代码并没有在我能看到的开源的部分里面了。

所以我这趟为了回答为什么 hw.epoch 为 0 的解谜之旅到这里就结束了。虽然我还是不知道为什么 hw.epoch 打印出来是 0 😂。因为在终端 sysctl -a 时,打印出来的列表已经不带 hw.epoch 了,但是如果用 sysctl -w hw.epoch 是可以显示结果的:

➜  darwin-xnu git:(master) sysctl -w hw.epoch
hw.epoch: 0

虽然这个问题有点无聊,但是寻找谜底的过程中却阅读了一部分 BSD 内核的代码,了解了 Darwin 的大致组成部分,知道使用 sysctl() 函数取 hw.machine 这种看上去有点奇怪的 API 内部的实现。就像某位参与某编译器项目的朋友说的,"there's no magic"。即使是高大上的内核,只要愿意读也是可以理解的,就是真的比较难读下去而已。

另外 sysctl.h 里有不少有用的 key 定义,做 iOS/Mac 开发的朋友们可以从这里面找找需要的东西,另外 IOKit 也有一些可插拔外设的信息。一般情况下我们开发 App 并不需要使用内核层的 API,但是如果上层 API 不够用的时候不妨到这一层来找找看。

内核系列文章

参考资料