
前面几篇关于 XNU 内核学习的文章里,经常会提到有些数据来自启动时外部传入的参数,比如 mem_size。因为内核本身也是一个巨大的程序,它也会被编译成二进制,然后在系统启动的时候加载到内存里,提供给上层诸如多核 CPU 运算,虚拟内存,线程,进程等一系列能力。
那么问题来了,内核是在什么时候被加载到内存里的呢?谁来负责调用内核的入口函数呢?整个计算的启动过程是怎样的呢?
我在阅读了 Amit Singh 的《Mac OS X Internals》一书中跟启动相关的章节之后,想以此文总结记录一下。希望看到详细内容的读者朋友们,我个人非常推荐 Amit 这本书,内容深入浅出,通俗易读。
我们知道系统内核也是一堆代码,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
所以要让内核这个大程序跑起来,首先得有人把这个文件读取后放进内存里,找到入口,然后调用,这个过程大概是这样的:
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 让旧机器更新固件,也就无法修复漏洞,所以越狱对于旧机器会一直有效。
这期间你甚至可以基于这个简单的系统开发软件,除了越狱之外还有很多可以做的。《Mac OS X Internals》提到 Open Firmware 还自带了 telnet, tftp 等工具,有点意思。
在 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 章节。
前面已经讲过 kernel 是一个 Mach-O 文件,这个文件的结构大概是这样的:

开始加载内核之前,系统提供了 otool 这个工具用于分析 Mach-O 文件,这个有意思我们可以介绍一下。
# file 命令查看 kernel 的文件格式 ➜ Kernels file kernel kernel: Mach-O 64-bit executable x86_64otool 命令 -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。
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_64 和 MH_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.h。kernel 打出来是 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。
Load command 就跟在 Mach Header 后面,应该算作 Header 的一部分,再往下就是编译好的二进制文件了。
Load Command 描述了文件的逻辑结构,以及文件在内存里的布局信息。内核执行 Mach-O 文件的实现在 bsd/kern/kern_exec.c,入口是 execve() 方法。在 parse_machfile() 方法中会遍历所有的 load commands 然后执行不同的命令,遇到 LC_MAIN 就会执行 load_main(),创建一个线程,加载函数主入口。
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() 函数加载到内存里,愉快地开机了。
看到这里不知道大家有没有个疑惑,就是 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 寄存器上的。
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)
不过可以看到上述代码声明了全局符号 _start 和 pstart 给链接器,并且 _start 和 pstart 底下的实现是一样的。所以编译优化后这两个函数的地址是一样的。
那么为什么入口是 _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
LC_MAIN 和 entryoffMac 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》这样的书是一种非常好的辅助。同时也建议读者朋友们不要只是读书,或者只是读代码。最好是两者结合动手实践一下,可以获得更深刻的理解。

在 macOS 内核之 CPU 占用率信息 | 枫言枫语 一文我们分析了 iOS 和 macOS 获取 CPU 占用信息的方法和内核的实现,本篇我们来看看内存信息的实现。
照例先从 iOS 开始。iOS 由于系统限制,App 层面只能获取自身的内存信息,无法获取其他 App 的内存信息。所以我们先看如何获取自己 App 的内存信息。
系统接口使用很简单,参考滴滴开源的 DoraemonKit 的实现如下:
+ (NSInteger)useMemoryForApp{
task_vm_info_data_t vmInfo;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
kern_return_t kernelReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
if(kernelReturn == KERN_SUCCESS)
{
int64_t memoryUsageInByte = (int64_t) vmInfo.phys_footprint;
return memoryUsageInByte/1024/1024;
}
else
{
return -1;
}
}
//设备总的内存
(NSInteger)totalMemoryForDevice{
return [NSProcessInfo processInfo].physicalMemory/1024/1024;
}
关键 API 还是 task_info(),取当前进程的信息,第一个参数为当前进程的 mach port(可参考上一篇讲过对这个 mach port 构造的实现),传入参数 TASK_VM_INFO 获取虚拟内存信息,后两个参数是返回值,传引用。
可以看到 task_vm_info_data_t 里的 phys_footprint 就是当前进程的内存占用,以 byte 为单位。腾讯开源的 Matrix亦使用一致的实现。
footprint 这个术语在 Apple 的文档里有曰过: Technical Note TN2434: Minimizing your app's Memory Footprint
有了当前进程的内存,再获取整个手机的内存,比一下就有当前进程的内存占用率了。获取手机的物理内存信息可以用 NSProcessInfo 的 API,如上面 DoraemonKit 的实现。也可以像腾讯的 Matrix 一样用 sysctl() 的接口:
+ (int)getSysInfo:(uint)typeSpecifier
{
size_t size = sizeof(int);
int results;
int mib[2] = {CTL_HW, (int) typeSpecifier};
sysctl(mib, 2, &results, &size, NULL, 0);
return results;
}
(int)totalMemory
{
return [MatrixDeviceInfo getSysInfo:HW_PHYSMEM];
}
kern_return_t
task_info(
task_t task,
task_flavor_t flavor,
task_info_t task_info_out,
mach_msg_type_number_t *task_info_count)
这个函数位于 osfmk/kern/task.c 内部实现并不复杂,大家可以直接看源码。
函数的第一个参数是用作内核与发起系统调用的进程做 IPC 通信的 mach port,第二个参数是获取信息的类型,函数里一顿 switch-case 猛如虎,剩下就是回传数据了。
我们看看 TASK_VM_INFO 的 case,这个case 和 TASK_VM_INFO_PURGEABLE 共享逻辑,后者会多一些 purgeable_ 开头的数据返回。
首先内核会判断调用方是内核进程还是用户进程,内核进程取内核的 map,用户进程去该进程的 map,并加锁。接着就是一顿 map 信息读取了。最后解锁。
// osfmk/kern/ledger.c // 赋值 vm_info->phys_footprint = (mach_vm_size_t) get_task_phys_footprint(task);// 取自 task_ledgers uint64_t get_task_phys_footprint(task_t task) { kern_return_t ret; ledger_amount_t credit, debit;
ret = ledger_get_entries(task->ledger, task_ledgers.phys_footprint, &credit, &debit); if (KERN_SUCCESS == ret) { return (credit - debit); } return 0;
}
task_ledgers 是内核维护的对该进程的"账本",每次为该进程分配和释放内存页的时候就往账本上记录一笔,并且分了多个不同的种类。
// osfmk/kern/task.c
void
init_task_ledgers(void)
这个初始化函数里大概创建了 30 种不同类型的账本,phys_footprint 是其中一个。
// osfmk/i386/pmap.h // osfmk/arm/pmap.h// 增加操作,即分配内存,以页为单位 #define pmap_ledger_debit(p, e, a) ledger_debit((p)->ledger, e, a)
// 减少操作,即释放内存,以页为单位 #define pmap_ledger_credit(p, e, a) ledger_credit((p)->ledger, e, a)
每次内核为该进程分配和释放内存时就往上记录一笔,以此来追踪进程的内存占用。这里假设各位读者都已了解虚拟内存以及为何按内存页(Memory Page)来分配的相关知识,如果有疑问可 Google 之。
pmap Mach 内核用来管理内存的一整套系统,代码古老且复杂,一个函数动辄四、五百行。而且 pmap 对于不同的机器有不同的实现,代码中区分了 i386 和 arm 两种实现。本人才疏学浅,一时半会也学不会,只能日后再做学习。不过通过以上代码追踪,我们可以知道为何在 iOS 中读取 phys_footprint 就能得到当前进程的内存占用。
task_vm_info_data_ 数据结构task_vm_info_data_t 里除了 phys_footprint 还有很多别的东西,我们可以看看这个结构体的定义:
#define TASK_VM_INFO 22 #define TASK_VM_INFO_PURGEABLE 23struct task_vm_info { // 虚拟内存大小,以 byte 为单位 mach_vm_size_t virtual_size; // Memory Region 个数 integer_t region_count; // 内存分页大小 integer_t page_size; // 实际物理内存大小,以 byte 为单位 mach_vm_size_t resident_size; // _peak 记录峰值,写入时会作比较,比原来的大才会更新 mach_vm_size_t resident_size_peak;
// 带 _peak 的都是运行过程中记录峰值的 mach_vm_size_t device; mach_vm_size_t device_peak; mach_vm_size_t internal; mach_vm_size_t internal_peak; mach_vm_size_t external; mach_vm_size_t external_peak; mach_vm_size_t reusable; mach_vm_size_t reusable_peak; mach_vm_size_t purgeable_volatile_pmap; mach_vm_size_t purgeable_volatile_resident; mach_vm_size_t purgeable_volatile_virtual; mach_vm_size_t compressed; mach_vm_size_t compressed_peak; mach_vm_size_t compressed_lifetime; /* added for rev1 */ mach_vm_size_t phys_footprint; /* added for rev2 */ mach_vm_address_t min_address; mach_vm_address_t max_address;
}; typedef struct task_vm_info task_vm_info_data_t;
在 macOS 上我们在终端运行 vm_stat 可以看到以下内存信息输出输出:
➜ darwin-xnu git:(master) vm_stat
Mach Virtual Memory Statistics: (page size of 4096 bytes)
Pages free: 349761.
Pages active: 1152796.
Pages inactive: 1090213.
Pages speculative: 22734.
Pages throttled: 0.
Pages wired down: 979685.
Pages purgeable: 519551.
"Translation faults": 300522536.
Pages copy-on-write: 16414066.
Pages zero filled: 94760760.
Pages reactivated: 4424880.
Pages purged: 4220936.
File-backed pages: 480042.
Anonymous pages: 1785701.
Pages stored in compressor: 2062437.
Pages occupied by compressor: 598535.
Decompressions: 4489891.
Compressions: 11890969.
Pageins: 6923471.
Pageouts: 38335.
Swapins: 87588.
Swapouts: 432061.
这个系统命令就是通过 host_statistics64() 获取的,代码可见这里。使用的是这个接口:
// osfmk/kern/host.c
kern_return_t
host_statistics64(host_t host, host_flavor_t flavor, host_info64_t info, mach_msg_type_number_t * count)
照例第一个参数填 mach_host_self(),用于跟内核 IPC。第二个参数是取的系统统计信息类型,我们要取内存,所以填 HOST_VM_INFO64。剩下两个就是返回的数据了。
返回的数据类型会 cast 成 vm_statistics64_t
// osfmk/mach/vm_statistics.h/*
- vm_statistics64
- History:
- rev0 - original structure.
- rev1 - added purgable info (purgable_count and purges).
- rev2 - added speculative_count.
----- rev3 - changed name to vm_statistics64.
changed some fields in structure to 64-bit onarm, i386 and x86_64 architectures.- rev4 - require 64-bit alignment for efficient access
in the kernel. No change to reported data.*/
struct vm_statistics64 { natural_t free_count; /* # 空闲内存页数量,没有被占用的 / natural_t active_count; / # 活跃内存页数量,正在使用或者最近被使用 / natural_t inactive_count; / # 非活跃内存页数量,有数据,但是最近没有被使用过,下一个可能就要干掉他 / natural_t wire_count; / # 系统占用的内存页,不可被换出的 / uint64_t zero_fill_count; / # Filled with Zero Page 的页数 / uint64_t reactivations; / # 重新激活的页数 inactive to active / uint64_t pageins; / # 换入,写入内存 / uint64_t pageouts; / # 换出,写入磁盘 / uint64_t faults; / # Page fault 次数 / uint64_t cow_faults; / # of copy-on-writes / uint64_t lookups; / object cache lookups / uint64_t hits; / object cache hits / uint64_t purges; / # of pages purged / natural_t purgeable_count; / # of pages purgeable / / * NB: speculative pages are already accounted for in "free_count", * so "speculative_count" is the number of "free" pages that are * used to hold data that was read speculatively from disk but * haven't actually been used by anyone so far. * / natural_t speculative_count; / # of pages speculative */
/* added for rev1 */ uint64_t decompressions; /* # of pages decompressed */ uint64_t compressions; /* # of pages compressed */ uint64_t swapins; /* # of pages swapped in (via compression segments) */ uint64_t swapouts; /* # of pages swapped out (via compression segments) */ natural_t compressor_page_count; /* # 压缩过个内存 */ natural_t throttled_count; /* # of pages throttled */ natural_t external_page_count; /* # of pages that are file-backed (non-swap) mmap() 映射到磁盘文件的 */ natural_t internal_page_count; /* # of pages that are anonymous malloc() 分配的内存 */ uint64_t total_uncompressed_pages_in_compressor; /* # of pages (uncompressed) held within the compressor. */} attribute((aligned(8)));
typedef struct vm_statistics64 *vm_statistics64_t; typedef struct vm_statistics64 vm_statistics64_data_t;
Page Fault 中文翻译为缺页错误之类,其实就是要访问的内存分页已经在虚拟内存里,但是还没加载到物理内存。这时候如果访问合法就从磁盘加载到物理内存,如果不合法(访问 nullptr 之类)就 crash 这个进程。详细解释可以参考这里。
Filled with Zero Page: 操作系统会维护一个 page,里面填满了 0,叫做 zero page。当一个新页被分配的时候,系统就往这个页里填 zero page。我的理解是相当于清空数据保护,防止其他进程读取旧数据吧。
空闲内存计算
speculative pages 是 OS X 10.5 引入的一个内核特性。内核先占用了这些 page,但是还没被真的使用,相当于预约。比如说当一个 App 在顺序读取硬盘数据的时候,内核发现它读完了 1, 2, 3 块, 那么很可能它会读 4。这时候内核先预约一块内存页准备给未来有可能会出现的 4。大概是这么个理解,可以参考这里的回答。
在上面的注释中,speculative pages 是被计入 vm_stat.free_count 里的,所以 vm_stat 的实现里,空闲内存的计算减去了这一部分:
pstat((uint64_t) (vm_stat.free_count - vm_stat.speculative_count), 8);
以上我们就得到了系统内存信息了。不过通过 host_statistics64() 接口取到的数据加一起并不等于系统物理内存,这是由内核统计实现决定了,这里有一个讨论有兴趣可以看看。
有了 active_count, speculative_count 和 wired_count,我们就可以计算内存占用率了?还差一个 compressed。
Memory Compression
内存压缩技术是从 OS X Mavericks (10.9) 开始引入的(iOS 则是 iOS 7 开始),可以参考官方文档:OS X Mavericks Core Technology Overview。
简单理解为系统会在内存紧张的时候寻找 inactive memory pages 然后开始压缩,以 CPU 时间来换取内存空间。所以 compressed 也要算进使用中的内存。另外还需要记录被压缩的 page 的信息,记录在 compressor_page_count 里,这个也要算进来。
(active_count + wired_count + speculative_count + compressor_page_count) * page_size
这才是最终的系统内存占用情况,以 byte 为单位。这个接口 host_statistics() 在 iOS 亦适用。
Mac 上的 iStat Menus App 就是这样计算内存占用的,但是,Activity Monitor.app 却有点不同。留意到他的 Memory Used 有一项叫做 App Memory。这个是根据 internal_page_count 来计算的,所以 Activity Monitor.app 的计算是这样的:
(internal_page_count + wired_count + compressor_page_count) * page_size
KSCrash 是一个开源的 Crash 堆栈信息捕捉库,里面有两个关于内存的函数:
static uint64_t freeMemory(void) { vm_statistics_data_t vmStats = {}; vm_size_t pageSize = 0; if(VMStats(&vmStats, &pageSize)) { return ((uint64_t)pageSize) * vmStats.free_count; } return 0; }
static uint64_t usableMemory(void) { vm_statistics_data_t vmStats = {}; vm_size_t pageSize = 0; if(VMStats(&vmStats, &pageSize)) { return ((uint64_t)pageSize) * (vmStats.active_count + vmStats.inactive_count + vmStats.wire_count + vmStats.free_count); } return 0; }
freeMemory() 是直接返回的 free_count,usableMemory() 则是 active_count + inactive_count + wire_count + free_count。
根据这两个函数的实现我猜测 freeMemory() 是想表达当前空闲内存的意思,usableMemory() 则是整个系统一共可以使用的内存有多少。
理论上 usableMemory 可以用硬件信息代替,但实际上系统接口返回的数据加一起一般都比物理内存少。使用这种方式计算我猜可能也是想获得更准备的系统实际可用内存吧。
但是根据上文我们已经知道,free_count 还包含了 speculative_count,最好去掉。并且 iOS 7 开始还加入了 memory compression,所以还得加上这个。
KSCrash 用的接口是 host_statistics(),这个接口没有返回 compression 相关的信息,猜测应该是这个项目开始的时候还没有 host_statistics64() 接口,或者当时 iPhone 的 64 位机器还不够普及(iPhone 5s 开始有 64 位机器)。
不过我自己实践了一下,即使用 host_statistics64() 接口,加上 compressions 和 compressor_page_count 之后的结果和不加的结果差不多。也有可能当时我的手机并没有使用大量内存所以压缩效果不明显就是。
mem: 2712944640
mem2: 2712961024
参考 Apple 官方文档 About the Virtual Memory System,Mac 上会有换页行为,也就是当物理内存不够了,就把不活跃的内存页暂存到磁盘上,以此换取更多的内存空间。
具体的步骤是:
但是在 iOS 上,系统不会有 page out 行为。这大概是 Apple 当年把 Darwin 系统移植到手机上时遇到的最头痛的问题之一:没有 swap 空间。桌面操作系统发展了几十年,有非常成熟的硬件条件,但是手机并不是。手机自带的空间也很小,属于珍贵资源,同时跟桌面硬件比起来,手机的闪存 I/O 速度太慢。所以普遍手机的操作系统都没有设计 swap。
所以一旦空闲内存下降到边界,iOS 的内核就会把 inactive 且没有修改过的内存释放掉,而且还可能会给正在运行的 App 发出内存警告,让 App 及时释放内存不然就之间挂掉,也就是俗称的"爆内存"(OOM Out-of-Memory)。
负责把 iOS App 干掉的杀手叫做 jetsam,这个东西在 Mac 上没有。
这篇 No pressure, Mon! Handling low memory conditions in iOS and Mavericks 和这篇 iOS内存abort(Jetsam) 原理探究 | SatanWoo 对于 jetsam 有些解析。不过 jetsam 相关的代码非常长,直接看的话是真的眼花缭乱。
看完这两篇文章之后我发现几个地方不太清楚,所以还是自己去走了一遍,但是我从最终的 kill 那一步反推回去,读起来比从一开始看 memory status 一步步往下走要容易一些。所以有兴趣看这部分代码的朋友,建议也从 memorystatus_do_kill() 反推回去。
arm_init()kernel_bootstrap()machine_startup()kernel_bootstrap()kernel_bootstrap_thread()bsd_init()memorystatus_init()memorystatus_thread()memorystatus_act_aggressive()memorystatus_kill_top_process()memorystatus_kill_proc()memorystatus_do_kill()jetsam_do_kill()exit_with_reason()thread_terminate()thread_terminate_internal()thread_apc_ast()thread_terminate_self()threadcnt == 0 时调用 proc_exit()一共 20 层之多,内核代码果然年代久远。 XD
其中 #1-#8 都是初始化,memorystatus_init() 里面创建了多个(hardcoded 为 3 个)最高优先级的内核线程:
int max_jetsam_threads = JETSAM_THREADS_LIMIT; #define JETSAM_THREADS_LIMIT 3
kernel_thread_start_priority(memorystatus_thread, NULL, 95 /* MAXPRI_KERNEL */, &jetsam_threads[i].thread);
以下条件命中时,会采取行动:
static boolean_t
memorystatus_action_needed(void)
{
#if CONFIG_EMBEDDED
return (is_reason_thrashing(kill_under_pressure_cause) ||
is_reason_zone_map_exhaustion(kill_under_pressure_cause) ||
memorystatus_available_pages <= memorystatus_available_pages_pressure);
#else /* CONFIG_EMBEDDED */
return (is_reason_thrashing(kill_under_pressure_cause) ||
is_reason_zone_map_exhaustion(kill_under_pressure_cause));
#endif /* CONFIG_EMBEDDED */
}
thrashing
kill_under_pressure_cause 为 thrashing 的条件:
kMemorystatusKilledFCThrashing
kMemorystatusKilledVMCompressorThrashing
kMemorystatusKilledVMCompressorSpaceShortage
会在这里触发 compressor_needs_to_swap(void),当内存需要换页的时候,arm 架构的实现就会判断当前 vm compressor 状态然后抛出上述三种 cause 之一,按照我的理解应该是内存压缩都开始告急了。
ZoneMapExhaustion
kill_under_pressure_cause 为 zone_map_exhaustion 的条件:
kMemorystatusKilledZoneMapExhaustion
这种情况则是由 kill_process_in_largest_zone() 函数发起,如果能找到 alloc 了最大 zone 的一个进程就干掉他,不行就记录 cause,走 jetsam 流程。
memorystatus_available_pages <= memorystatus_available_pages_pressure
或者是可用内存页少于系统设定的阈值,这个阈值计算如下:
unsigned long pressure_threshold_percentage = 15; unsigned long delta_percentage = 5;
memorystatus_delta = delta_percentage * atop_64(max_mem) / 100; memorystatus_available_pages_pressure = (pressure_threshold_percentage / delta_percentage) * memorystatus_delta;
相当于 atop_64(max_mem) * 15 / 100 也就是最大内存的 15%。max_mem 是 arm_vm_init() 启动时传入的,应该就是硬件内存大小了。
memorystatus_thread() 会先取一波原因:
/* Cause */
enum {
kMemorystatusInvalid = JETSAM_REASON_INVALID,
kMemorystatusKilled = JETSAM_REASON_GENERIC,
kMemorystatusKilledHiwat = JETSAM_REASON_MEMORY_HIGHWATER,
kMemorystatusKilledVnodes = JETSAM_REASON_VNODE,
kMemorystatusKilledVMPageShortage = JETSAM_REASON_MEMORY_VMPAGESHORTAGE,
kMemorystatusKilledProcThrashing = JETSAM_REASON_MEMORY_PROCTHRASHING,
kMemorystatusKilledFCThrashing = JETSAM_REASON_MEMORY_FCTHRASHING,
kMemorystatusKilledPerProcessLimit = JETSAM_REASON_MEMORY_PERPROCESSLIMIT,
kMemorystatusKilledDiskSpaceShortage = JETSAM_REASON_MEMORY_DISK_SPACE_SHORTAGE,
kMemorystatusKilledIdleExit = JETSAM_REASON_MEMORY_IDLE_EXIT,
kMemorystatusKilledZoneMapExhaustion = JETSAM_REASON_ZONE_MAP_EXHAUSTION,
kMemorystatusKilledVMCompressorThrashing = JETSAM_REASON_MEMORY_VMCOMPRESSOR_THRASHING,
kMemorystatusKilledVMCompressorSpaceShortage = JETSAM_REASON_MEMORY_VMCOMPRESSOR_SPACE_SHORTAGE,
};
如果是上一节 memorystatus_action_needed() 里的原因则走 memorystatus_kill_hiwat_proc()。hiwat 就是 high water。这时候不会立刻杀掉该进程,而是判断一下 phys_footprint 是否超过 memstat_memlimit,超过就干掉。
这一步如果成功杀掉了,那么这个循环就先结束,如果杀失败了,那就要开始愤怒模式了:
static boolean_t
memorystatus_act_aggressive(uint32_t cause, os_reason_t jetsam_reason, int *jld_idle_kills, boolean_t *corpse_list_purged, boolean_t *post_snapshot)
vm_pressure_thread 也会监控 VM Pressure,判断是否要杀进程。
memorystatus_pages_update() 会触发 vm pressure 检查,非常多地方会触发这个函数,已无力读下去。
不过最终大家都会会走 memorystatus_do_kill() 调用 jetsam_do_kill(),进入 exit_with_reason() 带一个 SIGKILL 信号。比较有意思是它的代码最末尾是:
/* Last thread to terminate will call proc_exit() */ task_terminate_internal(task);return(0);
我还以为是在 task_terminate_internal() 发了退出信号,但是并没有,这里面只是清理了 IPC 空间,map 之类的内核信息。注释说最后一个线程会调用 proc_exit(),原来是在这里调用的:
while (p->exit_thread != self) { if (sig_try_locked(p) <= 0) { proc_transend(p, 1); os_reason_free(exit_reason);if (get_threadtask(self) != task) { proc_unlock(p); return(0); } proc_unlock(p); thread_terminate(self); if (!thread_can_terminate) { return 0; } thread_exception_return(); /* NOTREACHED */ } sig_lock_to_exit(p); }
遍历所有线程,然后都调用 thread_terminate() 结束线程,这个函数的实现里面有判断 threadcnt == 0 时就调用 proc_exit(),这里面就会发送我们熟悉的 SIGKILL 信号然后退出进程了。
但是这些信息内核却并没有抛给应用,所以应用也不知道自己 OOM 了。参考 Tencent/matrix 的实现,也只能用排除法。
if (info.isAppCrashed) {
// 普通 crash 捕获框架能抓到的 crash
s_rebootType = MatrixAppRebootTypeNormalCrash;
} else if (info.isAppQuitByUser) {
// 用户主动关闭,来自 UIApplicationWillTerminateNotification
s_rebootType = MatrixAppRebootTypeQuitByUser;
} else if (info.isAppQuitByExit) {
// 利用 atexit() 注册回调
s_rebootType = MatrixAppRebootTypeQuitByExit;
} else if (info.isAppWillSuspend || info.isAppBackgroundFetch) {
// App 主动调用的,matrix 的注释曰: notify the app will suspend, help improve the detection of the plugins
if (info.isAppSuspendKilled) {
s_rebootType = MatrixAppRebootTypeAppSuspendCrash;
} else {
s_rebootType = MatrixAppRebootTypeAppSuspendOOM;
}
} else if ([MatrixAppRebootAnalyzer isAppChange]) {
// App 升级了
s_rebootType = MatrixAppRebootTypeAPPVersionChange;
} else if ([MatrixAppRebootAnalyzer isOSChange]) {
// 系统升级了
s_rebootType = MatrixAppRebootTypeOSVersionChange;
} else if ([MatrixAppRebootAnalyzer isOSReboot]) {
// 系统重启了
s_rebootType = MatrixAppRebootTypeOSReboot;
} else if (info.isAppEnterBackground) {
// 排除以上情况,剩下的就认为是 OOM,在后台就是后台 OOM
s_rebootType = MatrixAppRebootTypeAppBackgroundOOM;
} else if (info.isAppEnterForeground) {
// 在前台,判断下是否死锁
if (info.isAppMainThreadBlocked) {
// 死锁,来自 matrix 的卡顿监控,跟内存无关
s_rebootType = MatrixAppRebootTypeAppForegroundDeadLoop;
s_lastDumpFileName = info.dumpFileName;
} else {
// 前台 OOM
s_rebootType = MatrixAppRebootTypeAppForegroundOOM;
s_lastDumpFileName = @"";
}
} else {
s_rebootType = MatrixAppRebootTypeOtherReason;
}
iOS/Mac 获取内存占用信息的接口比较简单,但是涉及的概念和实现却非常复杂和庞大,尤其是内核的实现,一个函数动不动就 500 行以上,如果没有配套的书籍讲解,阅读起来十分吃力。所以读这种类型的代码,还是找到关键函数往回推比较简单点。XDDD
P.S. 使用 kill -l 命令可以看到所有的 tty 信号。SIGHUP 是 1,SIGKILL 是 9。所以我们经常使用的 kill -9 <pid> 命令就是告诉该进程你被 Kill 了。
P.P.S. memorystatus_do_kill() 函数的参数叫做 victim_p XDDD

在 iOS/Mac 上开发 App,当我们需要性能监控能力的时候,往往需要 CPU 信息来辅助追查:比如当前时刻是否 CPU 高占导致 App 卡到掉渣之类。
iOS 由于系统的限制,在不越狱的情况下无法获知整个系统的 CPU 信息,只能拿到自己 App 的所有线程信息,然后把 CPU 时间全部加起来得到一个大概的数值以供参考。可以参考腾讯开源的Matrix 的实现。代码太长我们只看核心部分:
// 取当前进程基础信息,其实不取也没有关系 kr = task_info(mach_task_self(), TASK_BASIC_INFO, (task_info_t) tinfo, &task_info_count);// 取当前进程的所有线程 kr = task_threads(mach_task_self(), &thread_list, &thread_count); // 遍历所有线程,取一波 CPU 时间 for (j = 0; j < thread_count; j++) { // 取一下线程信息 thread_info_count = THREAD_INFO_MAX; kr = thread_info(thread_list[j], THREAD_BASIC_INFO, (thread_info_t) thinfo, &thread_info_count); basic_info_th = (thread_basic_info_t) thinfo; // 计算一下时间和 CPU Usage,需要除以一个 TH_USAGE_SCALE 的 scale factor if (!(basic_info_th->flags & TH_FLAGS_IDLE)) { tot_sec = tot_sec + basic_info_th->user_time.seconds + basic_info_th->system_time.seconds; tot_usec = tot_usec + basic_info_th->system_time.microseconds + basic_info_th->system_time.microseconds; tot_cpu = tot_cpu + basic_info_th->cpu_usage / (float) TH_USAGE_SCALE * 100.0; } } // 最后释放一下 kr = vm_deallocate(mach_task_self(), (vm_offset_t) thread_list, thread_count * sizeof(thread_t));
或者滴滴开源的 DoraemonKit 的实现,跟上面的实现基本是一样的,只是省略了task_info()和user_time, system_time的计算。
留意到我们需要把 cpu_usage 取得的值除以 TH_USAGE_SCALE 后才能获得一个准确的值。为啥?这个东西用来干啥子的?
我们直接看看 darwin-xnu 对 thread_info() 的实现。这个函数只是简单地加了个锁,真正的实现在 thread_info_internal()。位置在 osfmk/kern/thread.c。
如果参数为 THREAD_BASIC_INFO 则走 retrieve_thread_basic_info()。这个函数先取了一波系统 timer 的数据给 user_time 和 system_time,然后就是重头戏了:
#define TH_USAGE_SCALE 1000/* * To calculate cpu_usage, first correct for timer rate, * then for 5/8 ageing. The correction factor [3/5] is * (1/(5/8) - 1). */ basic_info->cpu_usage = 0;#if defined(CONFIG_SCHED_TIMESHARE_CORE) if (sched_tick_interval) { basic_info->cpu_usage = (integer_t)(((uint64_t)thread->cpu_usage * TH_USAGE_SCALE) / sched_tick_interval); basic_info->cpu_usage = (basic_info->cpu_usage * 3) / 5; } #endif
if (basic_info->cpu_usage > TH_USAGE_SCALE) basic_info->cpu_usage = TH_USAGE_SCALE;
CONFIG_SCHED_TIMESHARE_CORE 这个宏应该是分时调度线程的意思,sched_tick_interval 则是定义在 osfmk/kern/sched.h 的一个全局变量。在分时调度逻辑初始化的时候,这个值被赋值:
// void sched_timeshare_timebase_init(void)
/* scheduler tick interval / // #define USEC_PER_SEC 1000000ull / microseconds per second */ // #define SCHED_TICK_SHIFT 3 clock_interval_to_absolutetime_interval(USEC_PER_SEC >> SCHED_TICK_SHIFT, NSEC_PER_USEC, &abstime); assert((abstime >> 32) == 0 && (uint32_t)abstime != 0); sched_tick_interval = (uint32_t)abstime;
这个值就是分时调度时(Time)每次 tick 的时间间隔,关于 FreeBSD 的分时模型(Time-sharing) 这里有篇文章可以参考一下。
void clock_interval_to_absolutetime_interval(uint32_t interval, uint32_t scale_factor, uint64_t * result) { uint64_t nanosecs = (uint64_t) interval * scale_factor; uint64_t t64;*result = (t64 = nanosecs / NSEC_PER_SEC) * rtclock_sec_divisor; nanosecs -= (t64 * NSEC_PER_SEC); *result += (nanosecs * rtclock_sec_divisor) / NSEC_PER_SEC;
}
NSEC_PER_SEC 是每一秒中有多少的纳秒(参考这里)。nanosecs / NSEC_PER_SEC 就得到秒了。
rtclock_sec_divisor 比较有意思。首先是 RTC,Real-time clock,中文翻译为实时时钟,是一个小小的时钟芯片,一般装在主板上,使用 CMOS 电池。读者朋友如果有装过 PC 的话应该会在主板上看到一个纽扣电池的卡槽,这个东西可以给 RTC 模块供电。
rtclock_sec_divisor 这个数值来自于以下函数:
static void
timebase_callback(struct timebase_freq_t * freq)
其中 freq 这个参数不同的平台有不同的实现。在时钟模块初始化的时候,内核会注册一个回调 PE_register_timebase_callback(timebase_callback); arm 架构的是是持有这个 callback 然后从硬件读取到相关信息后通过 callback 函数传回去:
void PE_call_timebase_callback(void) { struct timebase_freq_t timebase_freq;timebase_freq.timebase_num = gPEClockFrequencyInfo.timebase_frequency_hz; timebase_freq.timebase_den = 1; if (gTimebaseCallback) gTimebaseCallback(&timebase_freq);
}
timebase_freq_t 结构体的定义如下:
struct timebase_freq_t {
unsigned long timebase_num; // numerator 分子
unsigned long timebase_den; // denominator 分母
};
这种表示时间的方法叫做 Time Base,中文翻译为“时基”(注意这里所谓的时基和示波器的稍有不同,这里主要用作一个计时单位)。上面说到整个计算机的时序系统是建立在 RTC 模块上的,这个东西最重要的核心是一个时钟振荡器。目前多采用频率为 32.768 kHz (2^15) 的石英晶体制作。
在 arm 架构(iPhone)的实现中,timebase_freq 的分母被 hardcode 为 1。
i386(Mac)则取了总线频率做了如下运算:
void PE_call_timebase_callback(void) { struct timebase_freq_t timebase_freq; unsigned long num, den, cnt;num = gPEClockFrequencyInfo.bus_clock_rate_num * gPEClockFrequencyInfo.bus_to_dec_rate_num; den = gPEClockFrequencyInfo.bus_clock_rate_den * gPEClockFrequencyInfo.bus_to_dec_rate_den;
cnt = 2; while (cnt <= den) { if ((num % cnt) || (den % cnt)) { cnt++; continue; }
num /= cnt; den /= cnt;}
timebase_freq.timebase_num = num; timebase_freq.timebase_den = den;
if (gTimebaseCallback) gTimebaseCallback(&timebase_freq); }
gPEClockFrequencyInfo 里的东西在系统启动时由外部传入,应该是硬件信息。其中 arm 架构的实现还根据硬件的不同写了一堆转换,比如三星的 s3c2410 处理器,OMAP 的 OMAP3430 之类的。不过不知道用来做什么,the iPhone Wiki倒是提供了一个线索,大意是 2009 年在 MacRumors有人发了 iPhone 原型机的照片引起大家讨论。由于在系统的 /System/Library/Caches/com.apple.kernelcaches 里有一些其他 CPU 的处理,猜测是当时苹果不晓得要用哪一种 CPU 比较好,是遗留的代码。虽无法求证但是好像很有道理。
在判断完一系列架构之后,如果都不符合就把 timebase_frequency_hz 设置为默认值 24000000,然后在再用 IOKit 接口取 timebase-frequency:
/* Find the time base frequency first. */
if (DTGetProperty(cpu, "timebase-frequency", (void **)&value, &size) == kSuccess) {
/*
* timebase_frequency_hz is only 32 bits, and
* the device tree should never provide 64
* bits so this if should never be taken.
*/
if (size == 8)
gPEClockFrequencyInfo.timebase_frequency_hz = *(unsigned long long *)value;
else
gPEClockFrequencyInfo.timebase_frequency_hz = *value;
}
i386 的实现比较简单,基本就是 vstart() 函数里的启动参数 boot_args_start 带过来。
gPEClockFrequencyInfo.timebase_frequency_hz = 1000000000; gPEClockFrequencyInfo.bus_frequency_hz = 100000000; gPEClockFrequencyInfo.bus_clock_rate_hz = gPEClockFrequencyInfo.bus_frequency_hz; gPEClockFrequencyInfo.dec_clock_rate_hz = gPEClockFrequencyInfo.timebase_frequency_hz;gPEClockFrequencyInfo.bus_clock_rate_num = gPEClockFrequencyInfo.bus_clock_rate_hz; gPEClockFrequencyInfo.bus_clock_rate_den = 1;
gPEClockFrequencyInfo.bus_to_dec_rate_num = 1; gPEClockFrequencyInfo.bus_to_dec_rate_den = gPEClockFrequencyInfo.bus_clock_rate_hz / gPEClockFrequencyInfo.dec_clock_rate_hz;
所以 bus_clock_rate_num 是 100000000,bus_clock_rate_den 是 1。
bus_to_dec_rate_num 是 1, bus_clock_rate_hz 是 100000000, dec_clock_rate_hz 是 1000000000,所以 bus_to_dec_rate_den 是 0.1,但是要留意gPEClockFrequencyInfo.bus_clock_rate_hz / gPEClockFrequencyInfo.dec_clock_rate_hz这个式子里面,这两个参数都是 unsigned long,所以会变成 0。于是
// 100000000*1 num = gPEClockFrequencyInfo.bus_clock_rate_num * gPEClockFrequencyInfo.bus_to_dec_rate_num;
// 1*0 den = gPEClockFrequencyInfo.bus_clock_rate_den * gPEClockFrequencyInfo.bus_to_dec_rate_den;
i386 的 time base 中分子是 100000000 而分母是 0。这让我非常费解,因为底下还要对 den 做计算:
cnt = 2; while (cnt <= den) { if ((num % cnt) || (den % cnt)) { cnt++; continue; }num /= cnt; den /= cnt;
}
这段代码就废了,而且在 timebase_callback(struct timebase_freq_t * freq) 函数的实现中,0 是非法的:
static void timebase_callback(struct timebase_freq_t * freq) { unsigned long numer, denom; uint64_t t64_1, t64_2; uint32_t divisor;if (freq->timebase_den < 1 || freq->timebase_den > 4 || freq->timebase_num < freq->timebase_den) panic("rtclock timebase_callback: invalid constant %ld / %ld", freq->timebase_num, freq->timebase_den); denom = freq->timebase_num; numer = freq->timebase_den * NSEC_PER_SEC; // reduce by the greatest common denominator to minimize overflow if (numer > denom) { t64_1 = numer; t64_2 = denom; } else { t64_1 = denom; t64_2 = numer; } while (t64_2 != 0) { uint64_t temp = t64_2; t64_2 = t64_1 % t64_2; t64_1 = temp; } numer /= t64_1; denom /= t64_1; rtclock_timebase_const.numer = (uint32_t)numer; rtclock_timebase_const.denom = (uint32_t)denom; divisor = (uint32_t)(freq->timebase_num / freq->timebase_den); rtclock_sec_divisor = divisor; rtclock_usec_divisor = divisor / USEC_PER_SEC;
}
为了防止是我脑内运算出的问题,我还实际 copy 了一遍这段代码跑了一下,bus_to_dec_rate_den 为 0 无疑。既已如此,不找到负责这个内核开发的人是无法知道问题的答案了。
但是不管怎样我们现在知道 sched_tick_interval 是系统线程调度用的时间间隔,和硬件时钟频率有关。一开始的问题 TH_USAGE_SCALE 是在内核处理线程调度时,用在 ageing 算法的一个值,hardcode 为 1000,我们除以这个值就能获得一个 CPU 使用百分比数值 basic_info_th->cpu_usage / (float) TH_USAGE_SCALE * 100.0。这里涉及系统的线程优先级调度和 ageing 算法,我还没有完全搞明白,可以参考 Mac OS X Internals: A Systems Approach 一书。
macOS 通过内核接口 host_processor_info() 可以取到 CPU Load Info,这个接口定义在 mach_host.h,实现在 osfmk/kern/host.c。
接口定义如下:
kern_return_t
host_processor_info(host_t host,
processor_flavor_t flavor,
natural_t * out_pcount,
processor_info_array_t * out_array,
mach_msg_type_number_t * out_array_count)
host 是一个 mach port,传 mach_host_self() 就行。如果不知道 Mach Port 是什么可以参考 macOS 内核系列的上一篇 1.1 章节。
这里岔开聊一下 mach_host_self() 的实现。
// libsyscall/mach/mach_legacy.c mach_port_t mach_host_self(void) { return host_self_trap(); }// osfmk/kern/ipc_host.c mach_port_name_t host_self_trap( __unused struct host_self_trap_args *args) { // 取以前当前发起系统调用的进程返回一个
task_t,实际上就是mach_port_t。参考 2.2。 task_t self = current_task(); // 开源代码里没有ipc_port_t的定义但是有ipc_port,字面意义上理解这是发送端的 mach port ipc_port_t sright; // port 名字,简单理解为 ID mach_port_name_t name;
// 内核用的一个互斥锁,加锁 itk_lock(self); // copy 一下传入的 port 参数,如果是 active 的就计数 +1,如果不是就置为 DEAD,就是整数 0 // itk_host 是进程创建的时候内核分配的一个 special port,这个在我们上一篇也有提到。这个创建的源头来自ipc_init(),它的最上游就是各平台自己实现的启动入口,比如 i386 的i386_init(),应该就是开机后干的事情了。 sright = ipc_port_copy_send(self->itk_host); itk_unlock(self); // 这里有一个 space 的概念,可以看下面对current_space()实现的解释。 // 这里通过 space 和 sright 查找到 name 然后内部实现里操作一堆 table 信息的更新,返回 nanme name = ipc_port_copyout_send(sright, current_space()); // 最后返回给上层 return name; }
这就是内核如何创建一个自己的 mach port 然后返回给上层的过程。
顺便看下 current_space() 的实现:
// osfmk/kern/ipc_tt.c kr = ipc_space_create(&ipc_table_entries[0], &space);
// osfmk/ipc/ipc_space.h #define current_space_fast() (current_task_fast()->itk_space) #define current_space() (current_space_fast())
这个 ipc_space_t 主要是用来存储一个表 ipc_space_t,这个表记录了一堆 IPC 相关信息 ipc_entry_t。根据我粗浅的理解,应该是里面有 name 和 entry 的 KV 对应关系,可以互相查询,之前我们说过 name 并不需要全局唯一,内核可以自行查找匹配到对应的进程(task),应该就是通过这个 space 维护的表。
// bsd/kern/kern_prot.c #include <kern/task.h> /* for current_task() */// libsyscall/mach/mach/mach_init.h extern mach_port_t mach_task_self_; #define mach_task_self() mach_task_self_ #define current_task() mach_task_self()
// libsyscall/mach/mach_init.c mach_port_t mach_task_self_ = MACH_PORT_NULL;
void mach_init_doit(void) { // Initialize cached mach ports defined in mach_init.h mach_task_self_ = task_self_trap(); // ... }
current_task() 比较费解的是一路追过去发现它定义为 task_self_trap(),而这个函数上来就先调用了 current_task(),死循环了。
// osfmk/kern/ipc_tt.c
mach_port_name_t
task_self_trap(
__unused struct task_self_trap_args *args)
{
task_t task = current_task();
//…
}
不过 libsyscall/mach/mach_init.c 里引用了 osfmk/mach/mach_traps.h 里的定义 extern mach_port_name_t task_self_trap(void);。也有可能他的实现并不在 ipc_tt.c 里,但是我根本找不到就是了。
回到 host_processor_info() 这个函数,第一个参数填写由内核生成的自己进程的 mach port 用于 IPC,第二个参数则有以下定义:
/*
* Currently defined information.
*/
typedef int processor_flavor_t;
#define PROCESSOR_BASIC_INFO 1 /* basic information */
#define PROCESSOR_CPU_LOAD_INFO 2 /* cpu load information */
#define PROCESSOR_PM_REGS_INFO 0x10000001 /* performance monitor register info */
#define PROCESSOR_TEMPERATURE 0x10000002 /* Processor core temperature */
我们需要 CPU 占用率所以选第二个 PROCESSOR_CPU_LOAD_INFO,剩下的三个参数都是 out 参数,传引用就行。
processor_info_array_t cpuInfo;
mach_msg_type_number_t numCpuInfo;
natural_t numCPUsU = 0U;
kern_return_t err = host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO, &numCPUsU, &cpuInfo, &numCpuInfo);
四个参数可以获得不同的信息但是都会回传 processor_info_array_t,这是一个变长数组(variable-sized inline array):
/* processor_info_t: variable-sized inline array that can
* contain:
* processor_basic_info_t: (5 ints) 可以参考 PROCESSOR_BASIC_INFO_COUNT
* processor_cpu_load_info_t:(4 ints) 最大是 CPU_STATE_MAX
* processor_machine_info_t :(12 ints)
* If other processor_info flavors are added, this definition
* may need to be changed. (See mach/processor_info.h) */
type processor_flavor_t = int;
type processor_info_t = array[*:12] of integer_t;
type processor_info_array_t = ^array[] of integer_t;
CPU 占用率的数组 index 定义如下:
#define CPU_STATE_MAX 4
#define CPU_STATE_USER 0 #define CPU_STATE_SYSTEM 1 #define CPU_STATE_IDLE 2 #define CPU_STATE_NICE 3
由于现在的 Mac 基本都是多核 CPU,比如我的 Intel Core i7 CPU 有四核八线程,所以这个接口会返回每个线程 4 个 State 一共 32 个数据。我们可以通过 for 循环来取:
for(unsigned i = 0U; i < numCPUs; ++i) {
uint32_t inUser = (uint32_t)cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_USER];
uint32_t inSystem = (uint32_t)cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_SYSTEM];
uint32_t inNice = (uint32_t)cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_NICE];
uint32_t inIdle = (uint32_t)cpuInfo[(CPU_STATE_MAX * i) + CPU_STATE_IDLE];
}
numCPUs 就是八核,可以通过 sysctl() 传入 hw.cpu 来取。关于 sysctl() 接口可以参考之前的一篇文章,这里不再赘述。
扩展: 超线程 Hyper-threading
以前的 CPU 是一个物理核心对应一个物理线程,这里的线程和我们应用层的线程概念不一样。应用层可以开上百个线程,但是一个 CPU 可能只有一个核心,那么他只能把时间分片给不同的逻辑线程运行,由于速度太快所以感受不出来。后来英特尔开发了超线程技术(Hyper-threading)可以在一个物理核心里模拟出两个线程。那么对于系统内核来说,就相当于物理核心多了一倍。所以 i7 处理器通过
sysctl()取到的 CPU 个数就是 8 个。
user 是用户层 CPU 占用,system 是系统占用,nice 是老系统的遗留属性,现在是 hardcode 返回 0,不过源码没有删掉,idle 就是空闲 CPU 了。
按照之前的风格我们应该直接进入源码,不过这里先卖个关子。通过 host_processor_info() 取到的数据都是整数。直觉上我们认为把所有核心的 user + system + idle 就是全部 CPU,占用比全部就是 CPU 占用率了。
非常合理,有理有据。赶紧试一试。结果出来的百分比很奇怪,基本都在 7% 左右。用 Xcode 编译大项目 iStat Menu 都 100% 了这个结果值还是 7%。一定是哪里出了问题。
于是我参考了 Hammerspoon 的代码,htop 的代码,确认取 CPU Load Info 肯定没问题。那么有问题的可能是我对数据的处理方式。
留意到 Hammerspoon 关于 cpuUsageTick() 的文档 有曰这个接口取到的数据是自系统最近一次启动以来的的 ticks 数据。
前面只说 host_processor_info() 的数组里全是整数但是没说单位是啥。那么 ticks 是什么呢?
准确来讲并不是 CPU ticks 而是 clock ticks,用于计算 CPU 时间的单位。一般会实现一个系统时钟,每隔一个非常短的时间间隔就发起一个 CPU 中断请求,把 tick 计数加一。
但是 host_processor_info() 接口返回的数字都不算大,比如 CPU 比较空闲时 idle 比较多,大概是 121033877。这个数字相比于 CPU 每秒的频率也太小了吧。当然真实的数字是可以大到爆掉 UInt64 的,内核肯定做了 scaled,所以内核到底是怎么实现的呢?
主要实现在 osfmk/kern/processor.c 里的以下方法:
kern_return_t
processor_info(
processor_t processor,
processor_flavor_t flavor,
host_t *host,
processor_info_t info,
mach_msg_type_number_t *count)
switch-case 一下遇到 PROCESSOR_CPU_LOAD_INFO 后直接去读取相应的数值。
cpu_load_info = (processor_cpu_load_info_t) info; if (precise_user_kernel_time) { // #define PROCESSOR_DATA(processor, member) \ // (processor)->processor_data.member // processor 通过 osfmk/kern/processor.h 定义的全局变量来取,这里相当于读 processor->processor_data.user_state // timer_data_t user_state; // 拿到 user_state 之后再除以 hz_tick_interval // 在 osfmk/kern/clock.c 的实现中 hz_tick_interval 等于 NSEC_PER_SEC / 100,也就是 1/100 纳秒 cpu_load_info->cpu_ticks[CPU_STATE_USER] = (uint32_t)(timer_grab(&PROCESSOR_DATA(processor, user_state)) / hz_tick_interval); cpu_load_info->cpu_ticks[CPU_STATE_SYSTEM] = (uint32_t)(timer_grab(&PROCESSOR_DATA(processor, system_state)) / hz_tick_interval); } else { uint64_t tval = timer_grab(&PROCESSOR_DATA(processor, user_state)) + timer_grab(&PROCESSOR_DATA(processor, system_state));cpu_load_info->cpu_ticks[CPU_STATE_USER] = (uint32_t)(tval / hz_tick_interval); cpu_load_info->cpu_ticks[CPU_STATE_SYSTEM] = 0;
}
hz_tick_interval = 1000000000ull / 100 也就是 10^7,所以我们得到的结果被缩小了 10^7 倍,也就解释了为什么数字这么小了。
2019-11-1 updated: 后来我发现这里理解 tick 有问题
上面 host_processor_info() 获得的数字是内核时钟的 tick,在 XNU 里 hardcoded 为:
/* * The hz hardware interval timer. */
int hz = 100; /* GET RID OF THIS !!! / int tick = (1000000 / 100); / GET RID OF THIS !!! */
也就是一秒钟有 100 ticks,每个 CPU 核心(虚拟)自行计算,我取了其中一个的数据可以算出 3.8hr,同时打印 uptime 为 4hr 56m,略少一点。这是因为当系统 sleep 的时候 CPU 是不计算 ticks 的。所以这个计算是正确的,目前 tick 就是 hardcoded 为 100 次每秒。
顺便这两句 GET RID OF THIS !!! 的注释跟其他的 XXX 注释一样蜜汁幽默。
在 processor_info() 函数里还有这么一段注释:
/*
* We capture the accumulated idle time twice over
* the course of this function, as well as the timestamps
* when each were last updated. Since these are
* all done using non-atomic racy mechanisms, the
* most we can infer is whether values are stable.
* timer_grab() is the only function that can be
* used reliably on another processor's per-processor
* data.
*/
大意是由于 idle 状态下的 processor 不会经常更新自己的 idle time,所以在该函数内针对 idle 这个数值,判断 idle state 与否并取了两次 idle time 和 time stamp,比较一下再返回给上层。
// 取一下 idle 的 timer idle_state = &PROCESSOR_DATA(processor, idle_state); // 取第一次 idle state 数据 idle_time_snapshot1 = timer_grab(idle_state); // 取第一次时间戳 idle_time_tstamp1 = idle_state->tstamp;if (PROCESSOR_DATA(processor, current_state) != idle_state) { // 如果当前核心不在 idle 状态,那就是忙咯,忙就说明会经常更新,那么可信赖,直接用 cpu_load_info->cpu_ticks[CPU_STATE_IDLE] = (uint32_t)(idle_time_snapshot1 / hz_tick_interval); } else if ((idle_time_snapshot1 != (idle_time_snapshot2 = timer_grab(idle_state))) || (idle_time_tstamp1 != (idle_time_tstamp2 = idle_state->tstamp))){ // 如果是 idle 状态,再抓一次 state 和 timestamp 看看数据是否一致 // 由于此时数据有可能是并发更新的,那么第二次的数据比较新,有可能是更值得信赖的数据,用第二个 cpu_load_info->cpu_ticks[CPU_STATE_IDLE] = (uint32_t)(idle_time_snapshot2 / hz_tick_interval); } else { // 这里同样是 idle 状态,但是数据没有变化,那么大概率没有在并发更新,数据是稳定的,也可以直接用上 idle_time_snapshot1 += mach_absolute_time() - idle_time_tstamp1;
cpu_load_info->cpu_ticks[CPU_STATE_IDLE] = (uint32_t)(idle_time_snapshot1 / hz_tick_interval);
}
这样忙时的数据和 idle 数据都有了,nice 数据就是 hardcode 的 0
cpu_load_info->cpu_ticks[CPU_STATE_NICE] = 0;
关于 NICE
在历史上 Unix 系统有一个 nice 状态用来表示一个进程的执行优先级,-20 最高,19 最低。但是 Apple 的 Darwin-XNU 现在已经弃用了。我试了一下 htop 在 Mac 上的 NI 一列全是 0,但是在 Ubuntu 上 NI 一列有 0, -20, 19, 5 各种数字都有。可以参考阅读维基百科或者这篇文章。
timer_grab 方法留意到上面的注释里有一句:
timer_grab() is the only function that can be used reliably on another processor's per-processor data.
此时使用 timer_grab() 函数是唯一可以读取另外一个 processor 的 per-processor data 也就是 processor->processor_data。但是为什么呢?为什么 timer_grab() 是唯一可靠的函数呢?
我们看看 timer_grab() 方法的定义:
/*
* Read the accumulated time of `timer`.
*/
#if defined(__LP64__)
static inline
uint64_t
timer_grab(timer_t timer)
{
return timer->all_bits;
}
#else /* defined(__LP64__) */
uint64_t timer_grab(timer_t timer);
#endif /* !defined(__LP64__) */
在 64 系统上用静态内敛函数在头文件里实现了,直接返回 all_bits。在非 64 位系统则只是声明没有实现。我搜了整个 XNU 开源代码也没有实现。但是有另一个版本实现可以参考一下:
static uint64_t safe_grab_timer_value(struct timer *t)
{
#if defined(__LP64__)
return t->all_bits;
#else
uint64_t time = t->high_bits; /* endian independent grab */
time = (time << 32) | t->low_bits;
return time;
#endif
}
其实这个 if-else 的区别只是因为 64 位和 32 位的区别而已:
struct timer {
uint64_t tstamp;
#if defined(__LP64__)
uint64_t all_bits;
#else /* defined(__LP64__) */
/* A check word on the high portion allows atomic updates. */
uint32_t low_bits;
uint32_t high_bits;
uint32_t high_bits_check;
#endif /* !defined(__LP64__) */
};
在 32 位系统上,内核用两个 uint32_t 来分开记录高位和低位数值,然后返回的时候拼成一个大的 64 位 uint64_t。一开始我以为 timer_grab() 是为了线程安全之类的,但是大家都只是读数值又不是写操作,而且看这个 safe 版本的实现,跟线程安全什么的没关系。所以应该只是因为要兼容,timer_grabe() 才是 only function。
Timer 计时的地方有点多,我还需要理解内核时钟的原理只能知道细节,这里大概看一下 Timer 的数据结构和 API。
struct timer {
uint64_t tstamp;
uint64_t all_bits;
};
非 64 位的直接不看了,原理是一样的,存储结构不同而已。最关键的是 tstamp 这个 time stamp。 timer_start() 时会记录当前时间戳,timer_stop(), timer_update(), timer_switch() 都会调用 timer_advance(),计算两次时间戳的差异,加到 all_bits 上面。
所以简单理解就是每次 CPU 把分配给了 user 或者 system 的时候,就会开启对应 timer 的计时,可以在二者之间切换时,或者闲时之类的变化就改变 timer 状态,更新计时数据。
传入的时间从 mach_absolute_time() 获得。
这个时间的实现 arm 和 i386 还不一样。
1386 的最终会到这里:
static inline uint64_t
rtc_nanotime_read(void)
{
return _rtc_nanotime_read(&pal_rtc_nanotime_info);
}
不过 _rtc_nanotime_read() 没有 C 实现,可能是汇编实现。但是反正读的是当前的 RTC 时间,以纳秒为单位。
arm 的实现则是:
uint64_t mach_absolute_time(void) { return ml_get_timebase(); }
uint64_t ml_get_timebase() { return (ml_get_hwclock() + getCpuDatap()->cpu_base_timebase); }
为什么要两者相加呢?因为 cpu_base_timebase 在初始化的赋值是这样的:
if (!from_boot && (cdp == &BootCpuData)) {
/*
* When we wake from sleep, we have no guarantee about the state
* of the hardware timebase. It may have kept ticking across sleep, or
* it may have reset.
*
* To deal with this, we calculate an offset to the clock that will
* produce a timebase value wake_abstime at the point the boot
* CPU calls cpu_timebase_init on wake.
*
* This ensures that mach_absolute_time() stops ticking across sleep.
*/
rtclock_base_abstime = wake_abstime - ml_get_hwclock();
}
cdp->cpu_base_timebase = rtclock_base_abstime;
rtclock_base_abstime 这个就是 uint64_t 的 RTC 时间,保存在 rtclock_data_t 的 rtc_base 结构体里,也是纳秒。
extern rtclock_data_t RTClockData;
#define rtclock_base_abstime RTClockData.rtc_base.abstime
这个初始化函数 void cpu_timebase_init(boolean_t from_boot) 会被调用多次,系统启动的时候可以直接取 rtclock_base_abstime,但是如果从睡眠中唤醒,有可能时钟已经不跑了,所以要计算一个差值。
初始化是 rtclock_base_abstime 为 0。在所有核心 sleep 时 ml_arm_sleep(void) 函数记录一个时间到 wake_abstime。这个值通过 ml_get_timebase() 获取,此时如果从未 sleep 过则为硬件时钟时间 ml_get_hwclock()。
当 CPU 被唤醒时计算差值 wake_abstime - ml_get_hwclock(),保存到 cpu_base_timebase。
这样当你读取 ml_get_timebase() 时就加上这段差值,结果得到的是上一次保存的 wake_abstime,相当于从上一次 sleep 的地方开始继续往前 tick。
虽然注释说有可能 hwclock() 在睡眠期间会继续 tick 也有可能不会,所以要修正,不过我还不清楚修正是为了什么。可能内核需要用到这个时间来做些什么事情吧。
回到一开始用 host_processor_info() 的数据来计算占用率不准问题,因为我们用的是历史数据,我们应该关注的是一小段时间内的 CPU 数据,比如取时间 t1 和时间 t2 的 cpu load,然后作差值。这个差值就反应了 t1 到 t2 之间 CPU 的占用情况。所以修正一下上面的做法,只需要取两次样本,然后相减,得到的数据再做一次忙时除以全部的 ticks 就能得到 CPU 占用率了。
Hammerspoon 里提供了一个用 LUA 封装的简单采用方法 hs.host.cpuUsage([period], [callback]) -> table 可供使用。
源码可以参考这里。
local convertToPercentages = function(result1, result2)
local result = {}
for k,v in pairs(result2) do
if k == "n" then
result.n = v
else
result[k] = {}
for k2, v2 in pairs(v) do
result[k][k2] = v2 - result1[k][k2]
end
local total = result[k].active + result[k].idle
for k2, _ in pairs(result[k]) do
result[k][k2] = (result[k][k2] / total) * 100.0
end
end
end
for i,v in pairs(result) do
if tostring(i) ~= "n" then
result[i] = setmetatable(v, { __tostring = __tostring_for_tables })
end
end
return result
end
非常简单地两个结果作差值。
本文从 iOS 和 Mac 取 CPU 占用率的接口出发,简单介绍了 Time Base 的概念,RTC 时钟,内核层维护 space 和 table 以记录 mach port 和进程相关信息,CPU Ticks 等内核层用到的东西。
操作系统越是往下走跟硬件设计打交道的东西就越多。平时做顶层面向用户的 App 开发基本不会碰到这些东西。对于 CPU 占用率这种代码,到 stackoverflow 抄一下就能用了。这并没有问题,但是探求一个系统接口的实现,寻找知其所以然的过程也十分有趣。
系统内核的实现有些地方需要高超的算法能力,比如线程调度模型,有些地方需要追去稳定,还有些地方可能用了 C/C++ 的语法糖之类的,看起来有点困难。但实际上和平时开发一个 App 需求的路子是一样的,就是分析一个问题,找到一个问题的解决方法而已。
当然了阅读和理解内核代码很容易,但是实践写出一个内核却是难如登天的一件事情,不仅非常强算法能力,也要求具备大型项目的管理能力。所以虽然我写不了内核,看一看这些神秘的 API 背后的实现也是很有意思的。
updated: osfmk 目录下的代码就是 Mach 内核部分,由于进程是在 Mach 内核实现的,所以我们可以通过 Mach 内核接口获取相关信息。host_info() 类型的接口都由 Mach 内核提供。

在上一篇macOS 内核之 hw.epoch 是个什么东西?我们提到 XNU 内核包含了 BSD 和 Mach,其中 Mach Kernel 提供了 I/O Kit 给硬件厂商写驱动用的。这个部分在 NeXT 时期是用 Objective-C 提供的 API,叫做 Driver Kit,后来乔布斯回到苹果之后,升级了 BSD 和 Mach 的代码,于是在 OS X 中提供了 C++ 接口的 I/O Kit。
根据官方的这份文档,以下系统支持 I/O Kit:
I/O Kit 里我们可以通过三种不同的方式获取电池信息,位于 IOKit/pwr_mgt 的 Power Mangement 接口,位于 IOKit/ps 的 Power Sources 接口,以及通过 IOServiceGetMatchingService 获取 AppleSmartBattery Service 接口。
IOPM 接口需要使用 Mach Port 跟 IOKit 进行 IPC 通信,所以我们先来了解一点 Mach Port 的背景。
XNU 是一个混合内核,既有 BSD 又有 Mach Kernel,上层还有各种各样的技术,所以在 macOS 系统中,IPC (跨进程通信)的技术也多种多样。Mattt 在 NSHipster 上写过一篇 IPC 的文章: Inter-Process Communication - NSHipster 对此有过详解。
Mach Port 是在系统内核实现和维护的一种 IPC 消息队列,持有用于 IPC 通信的 mach messages。只有一个进程可以从对应的 port 里 dequeue 一条消息,这个进程被持有接收权利(receive-right)。可以有多个进程往某个 port 里 enqueue 消息,这些进程持有该 port 的发送权利(send-rights)。

如上图,PID 123 的进程往一个 port 里发送了一条消息,只有对应的接收端 PID 456 才能从 port 里取出这条消息。
我们可以简单把 mach port 看做是一个单向的数据发送渠道,构建一个消息结构体后通过mach_msg() 方法发出去。因为只能单向发送,所以当 B 进程收到了 A 进程发来的消息之后要自己创建一个新的 Port 然后又发回去 A 进程。
手动构建 mach message 发送是比较复杂的,大概长这个样子(代码来自 Mattt 的那篇文章):
natural_t data; mach_port_t port;struct { mach_msg_header_t header; mach_msg_body_t body; mach_msg_type_descriptor_t type; } message;
message.header = (mach_msg_header_t) { .msgh_remote_port = port, .msgh_local_port = MACH_PORT_NULL, .msgh_bits = MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0), .msgh_size = sizeof(message) };
message.body = (mach_msg_body_t) { .msgh_descriptor_count = 1 };
message.type = (mach_msg_type_descriptor_t) { .pad1 = data, .pad2 = sizeof(data) };
mach_msg_return_t error = mach_msg_send(&message.header);
if (error == MACH_MSG_SUCCESS) { // ... }
其中最关键的是 msgh_remote_port 和 msgh_local_port。上述代码是发送消息,所以 msgh_remote_port 就是要接收这条消息的那个进程的 port。我们得先知道这个 port 信息我们才能往里面发消息。另外例子中使用的是 mach_msg_send() 函数。
留意到在上图中,PID 123 往一个名为 0xabc 的 port 发消息,PID 456 则从名为 0xdef 的 port 里取消息。这里 port name 只对当前进程有意义,并不需要全局一致,内核会自动根据进程 ID 和名字信息找到对应的进程。
我们的代码在用户层调用,需要进出内核层,这是一进一出如果消息体里带上大量的信息就会非常慢。所以如果需要使用 mach message 来发送体积较大的信息,可以使用 “out-of-line memory” descriptor。
我们看到上面 Mattt 的代码使用 mach_msg_send() 函数来发送消息,message.body 带了一个 msgh_descriptor_count 为 1。这个 descriptor 是一个 natural_t。我看到这里的时候并没有搞懂系统是怎么做 OOL 的 copy-on-write 的。于是照例翻一下 XNU 的源码,我发现 Mattt 的例子并没有使用 OOL descriptor,而是使用了 type descriptor。
typedef struct
{
natural_t pad1;
mach_msg_size_t pad2;
unsigned int pad3 : 24;
mach_msg_descriptor_type_t type : 8;
} mach_msg_type_descriptor_t;
ool descriptor 的结构如下:
typedef struct
{
uint64_t address;
boolean_t deallocate: 8;
mach_msg_copy_options_t copy: 8;
unsigned int pad1: 8;
mach_msg_descriptor_type_t type: 8;
mach_msg_size_t size;
} mach_msg_ool_descriptor64_t;
使用时我们需要把内存地址发过去,内核只负责传递地址指针,等到进程接受到了这条消息之后才会从内存里 copy buffer。
在 IOKit 里面,所有的通信都通过 IOKit Master Port 来进行,使用以下函数可以获取 master port。
kern_return_t
IOMasterPort( mach_port_t bootstrapPort,
mach_port_t * masterPort );
实际使用时如下:
mach_port_t masterPort;
IOMasterPort(MACH_PORT_NULL, &masterPort)
默认把 bootstrapPort 置空。如果返回值是 kIOReturnSuccess 就成功构建了一个 mach_port_t 用于跟 IOKit 通信。
不过在这个 API 里面,获取单一 master port 好理解,那 bootstrapPort 这个参数又是用来干啥的呢?
在上面的例子中 PID 123 和 PID 456 是在已经获知对方的 port name 的前提下才有办法互相通信的。但是如果你不知道对方的 port name 呢?于是 XNU 系统提供了 bootstrap port 这个东西,由系统提供查询服务,这样所有的进程都可以去广播自己的 mach port 接收端的名字,也可以查询其他人的名字。
查询接口大概是这样:
mach_port_t port;
kern_return_t kr = bootstrap_look_up(bootstrap_port, "me.justinyan.example", &port);
注册接口大概是这样:
bootstrap_register(bootstrap_port, "me.justinyan.example", port);
同时 bootstrap port 是一个特殊的 port。其他的 mach port 在父进程被 fork() 的时候,子进程是不会继承 port 的,只有 bootstrap port 可以被继承。
但是,自从 OS X 10.5 开始,苹果引入了 Launchd 这么一个服务,同时弃用了 bootstrap_register() 接口。关于这件事情当时 darwin 开发团队有个长长的邮件列表做了激烈的讨论: Apple - Lists.apple.com
新的接口可以参考 CFMessagePortCreateLocal() 和这篇文章: Damien DeVille | Interprocess communication on iOS with Mach messages
上面罗里吧嗦一大堆全是 mach port 的事情,现在终于到正题了。代码非常简单:
NSDictionary* get_iopm_battery_info() { mach_port_t masterPort; CFArrayRef batteryInfo;if (kIOReturnSuccess == IOMasterPort(MACH_PORT_NULL, &masterPort) && kIOReturnSuccess == IOPMCopyBatteryInfo(masterPort, &batteryInfo) && CFArrayGetCount(batteryInfo)) { CFDictionaryRef battery = CFDictionaryCreateCopy(NULL, CFArrayGetValueAtIndex(batteryInfo, 0)); CFRelease(batteryInfo); return (__bridge_transfer NSDictionary*) battery; } return NULL;}
NSDictionary *dict = get_iopm_battery_info(); NSLog(@"iopm dict: %@", dict);
输出:
iopm dict: {
Amperage = 0;
Capacity = 6360;
Current = 6360;
"Cycle Count" = 113;
Flags = 5;
Voltage = 12968;
}
可以看到电池循环次数、容量之类的信息,但是不多。IOPMLib.h 的注释说 不建议大家使用这个接口,可以考虑用 IOPowerSources API 代替。
IOPowerSources 的接口比较简单,先用 IOPSCopyPowerSourcesInfo() 取到 info, 然后取 IOPSCopyPowerSourcesList(),最后再 copy 一下就完事了。
NSDictionary* get_iops_battery_info() { CFTypeRef info = IOPSCopyPowerSourcesInfo();if (info == NULL) return NULL; CFArrayRef list = IOPSCopyPowerSourcesList(info); // Nothing we care about here... if (list == NULL || !CFArrayGetCount(list)) { if (list) CFRelease(list); CFRelease(info); return NULL; } CFDictionaryRef battery = CFDictionaryCreateCopy(NULL, IOPSGetPowerSourceDescription(info, CFArrayGetValueAtIndex(list, 0))); // Battery is released by ARC transfer. CFRelease(list); CFRelease(info); return (__bridge_transfer NSDictionary* ) battery;}
NSDictionary *iopsDict = get_iops_battery_info(); NSLog(@"iops dict: %@", iopsDict);
输出:
iops dict: {
"Battery Provides Time Remaining" = 1;
BatteryHealth = Good;
Current = 0;
"Current Capacity" = 100;
DesignCycleCount = 1000;
"Hardware Serial Number" = D**********;
"Is Charged" = 1;
"Is Charging" = 0;
"Is Present" = 1;
"Max Capacity" = 100;
Name = "InternalBattery-0";
"Power Source ID" = 9764963;
"Power Source State" = "AC Power";
"Time to Empty" = 0;
"Time to Full Charge" = 0;
"Transport Type" = Internal;
Type = InternalBattery;
}
可以看到信息多了很多,还有 BatteryHealth 等信息,我们看到我的 MacBook 的电池设计循环次数是 DesignCycleCount = 1000,然后我已经循环 113 次了。
但是,这批信息里面没有带电池的设计容量。
IOKit 里提供了一套 IOService 相关的接口,你可以往里面注册 IOService 服务,带个名字,一样是通过 IOMasterPort() 来通信。IOKit 主要是面向硬件驱动开发者的,所以如果你的硬件依赖另外一个硬件,但是另外一个硬件还没有接入,这时候你可以往 IOService 注册一个通知。使用 IOServiceAddMatchingNotification,等到你观察的硬件接入后调用了 registerService() 你就会收到对应的通知了。
这里我们直接用 IOServiceGetMatchingService() 来获取系统提供的 AppleSmartBattery service。
NSDictionary* get_iopmps_battery_info() { io_registry_entry_t entry = 0; entry = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceNameMatching("AppleSmartBattery")); if (entry == IO_OBJECT_NULL) return nil;CFMutableDictionaryRef battery; IORegistryEntryCreateCFProperties(entry, &battery, NULL, 0); return (__bridge_transfer NSDictionary *) battery;}
NSDictionary *iopmsDict = get_iopmps_battery_info(); NSLog(@"iopmsDict: %@", iopmsDict);
输出:
iopmsDict: {
AdapterDetails = {
Current = 4300;
PMUConfiguration = 2092;
Voltage = 20000;
Watts = 86;
};
AdapterInfo = 0;
Amperage = 0;
AppleRawAdapterDetails = (
{
Current = 4300;
PMUConfiguration = 2092;
Voltage = 20000;
Watts = 86;
}
);
AppleRawCurrentCapacity = 6360;
AppleRawMaxCapacity = 6360;
AvgTimeToEmpty = 65535;
AvgTimeToFull = 65535;
BatteryData = {
AdapterPower = 1106486026;
CycleCount = 113;
DesignCapacity = 6669;
PMUConfigured = 0;
QmaxCell0 = 6812;
QmaxCell1 = 6859;
QmaxCell2 = 6784;
ResScale = 200;
StateOfCharge = 100;
SystemPower = 4625;
Voltage = 12968;
};
BatteryFCCData = {
DOD0 = 128;
DOD1 = 144;
DOD2 = 128;
PassedCharge = 0;
ResScale = 200;
};
BatteryInstalled = 1;
BatteryInvalidWakeSeconds = 30;
BatterySerialNumber = D**********;
BestAdapterIndex = 3;
BootPathUpdated = 1571194014;
CellVoltage = (
4323,
4322,
4323,
0
);
ChargerData = {
ChargingCurrent = 0;
ChargingVoltage = 13020;
NotChargingReason = 4;
};
CurrentCapacity = 6360;
CycleCount = 113;
DesignCapacity = 6669;
DesignCycleCount70 = 0;
DesignCycleCount9C = 1000;
DeviceName = bq20z451;
ExternalChargeCapable = 1;
ExternalConnected = 1;
FirmwareSerialNumber = 1;
FullPathUpdated = 1571290629;
FullyCharged = 1;
IOGeneralInterest = "IOCommand is not serializable";
IOReportLegend = (
{
IOReportChannelInfo = {
IOReportChannelUnit = 0;
};
IOReportChannels = (
(
7167869599145487988,
6460407809,
BatteryCycleCount
)
);
IOReportGroupName = Battery;
}
);
IOReportLegendPublic = 1;
InstantAmperage = 0;
InstantTimeToEmpty = 65535;
IsCharging = 0;
LegacyBatteryInfo = {
Amperage = 0;
Capacity = 6360;
Current = 6360;
"Cycle Count" = 113;
Flags = 5;
Voltage = 12968;
};
Location = 0;
ManufactureDate = 19722;
Manufacturer = SMP;
ManufacturerData = {length = 27, bytes = 0x00000000 *** };
MaxCapacity = 6360;
MaxErr = 1;
OperationStatus = 58433;
PackReserve = 200;
PermanentFailureStatus = 0;
PostChargeWaitSeconds = 120;
PostDischargeWaitSeconds = 120;
Temperature = 3067;
TimeRemaining = 0;
UserVisiblePathUpdated = 1571291169;
Voltage = 12968;
}
可以看到比前面的两次输出多了很多。
CurrentCapacity = 6360;
DesignCapacity = 6669;
有了当前电池容量和设计容量,就可以得到我的电池还剩 95% 的容量。
以上三种方法我都是从 Hammerspoon 的源码中习得。通过阅读这部分接口学习了相关的一些内核层 API 的概念,很有意思。那么在 #3 中 Hammerspoon 的作者是怎么知道系统有一个 IOService 叫做 "AppleSmartBattery" 的呢?我们不妨把系统所有的 IOService 打印出来,然后 grep 看看里面有没有带 battery 或者 energy 关键字的。
IOKitLib.h 里有一个接口 IORegistryCreateIterator() 可以创建一个迭代器,把所有已注册的 IOService 取出来。
核心代码如下:
const char *plane = "IOService";
io_iterator_t it = MACH_PORT_NULL;
IORegistryCreateIterator(kIOMasterPortDefault, plane, kIORegistryIterateRecursively, &it)
有一个开源库实现了这个功能,有兴趣的读者朋友可以看看这里: Siguza/iokit-utils: Dev tools for probing IOKit
➜ iokit-utils ./ioprint| grep -i battery
AppleSmartBatteryManager(AppleSmartBatteryManager)
AppleSmartBattery(AppleSmartBattery)
结果出来两个 battery 相关的,AppleSmartBattery 就是上述例子用到的,AppleSmartBatteryManager 则打印出如下结果:
iopmsDict: {
CFBundleIdentifier = "com.apple.driver.AppleSmartBatteryManager";
CFBundleIdentifierKernel = "com.apple.driver.AppleSmartBatteryManager";
IOClass = AppleSmartBatteryManager;
IOMatchCategory = IODefaultMatchCategory;
IOPowerManagement = {
CapabilityFlags = 2;
CurrentPowerState = 1;
MaxPowerState = 1;
};
IOProbeScore = 0;
IOPropertyMatch = {
IOSMBusSmartBatteryManager = 1;
};
IOProviderClass = IOSMBusController;
IOUserClientClass = AppleSmartBatteryManagerUserClient;
}
只是一堆苹果自家驱动的信息而已。
我在运行了 iOS 13.1.2 的 iPhone Xs Max 机器上进行了测试。iOS 工程引入 IOKit 会比较麻烦,因为这个 Framework 是不公开的,所以你得把所有的头文件导出来,并且把 #import <IOKit/xxx.h> 的地方都改掉。可以参考此文: [Tutorial] Import IOKit framework into Xcode project | Gary's ...Lasamia
实测 IOPMCopyBatteryInfo 在 iOS 上无效,估计是 iOS 直接不给 mach port 权限到上层。 IOPSCopyPowerSourcesList 和 IOServiceNameMatching 能用。
iops dict: {
"Battery Provides Time Remaining" = 1;
"Current Capacity" = 100;
"Is Charged" = 1;
"Is Charging" = 0;
"Is Present" = 1;
"Max Capacity" = 100;
Name = "InternalBattery-0";
"Play Charging Chime" = 1;
"Power Source ID" = 2490467;
"Power Source State" = "AC Power";
"Raw External Connected" = 1;
"Show Charging UI" = 1;
"Time to Empty" = 0;
"Time to Full Charge" = 0;
"Transport Type" = Internal;
Type = InternalBattery;
}
iopmsDict: {
BatteryInstalled = 1;
ExternalConnected = 1;
}
可以看到信息比 macOS 的少了很多,并且没有包含 cycleCount 这个信息。
但是毕竟 iOS 是有 IOKit 框架的,那么有没有什么奇技淫巧可以拿到 IOKit 的信息呢?eldade/UIDeviceListener: Obtain power information (battery health, charger details) for iOS without any private APIs.这个库可以在 iOS 7 - iOS 9.3 上捕获这部分信息。
所使用之操作也是非常有趣。从 iOS 3.0 开始,UIDevice 增加了 batteryState 和 batteryLevel 这两个参数,并且允许开启电池监控 batteryMonitoringEnabled。通过上文我们已经知道,这些操作最终都是通过 IOKit 来进行的。
IOKit 会从 IORegistry 获取一份最新的电池信息,就像我们的 get_iopmps_battery_info() 方法一样。留意到从 IORegistry 取数据的接口长这样:
IORegistryEntryCreateCFProperties(
io_registry_entry_t entry,
CFMutableDictionaryRef * properties,
CFAllocatorRef allocator,
IOOptionBits options );
重点在第三个参数 CFAllocatorRef,通常情况下系统会用默认的 CFAllocatorGetDefault()。我们看看这个 allocator 长啥样CoreFoundation/CFBase.c:
typedef const struct CF_BRIDGED_TYPE(id) __CFAllocator * CFAllocatorRef;
// CFAllocator structure must match struct _malloc_zone_t! // The first two reserved fields in struct _malloc_zone_t are for us with CFRuntimeBase struct __CFAllocator { CFRuntimeBase _base; CFAllocatorRef _allocator; CFAllocatorContext _context; };
以及 CoreFoundation 提供了不少操作:
CFAllocatorGetDefault();
CFAllocatorGetContext();
CFAllocatorCreate();
CFAllocatorSetDefault();
如果能把系统的默认 allocator 替换成自己的实现,那么当我们打开 batteryMonitoringEnabled 然后电池发生变更的时候,系统就回去用 IORegistry 取一份电池信息,就会掉进我们替换掉的 allocator。这时候就能截取 allocator 刚刚 allocate 的内存信息了。真的佩服作者的脑洞。详细的实现大家可以看原来的库: eldade/UIDeviceListener,我们只看关键代码:
// 获取默认 allocator _defaultAllocator = CFAllocatorGetDefault();CFAllocatorContext context;
// 获取默认 allocator 的 context
CFAllocatorGetContext(_defaultAllocator, &context);// 全部改成自己的实现, myAlloc/myRealloc/myFree 都是 C 函数 context.allocate = myAlloc; context.reallocate = myRealloc; context.deallocate = myFree;
// 用修改后的 context 创建新的 allocator _myAllocator = CFAllocatorCreate(NULL, &context);
// 把自己创建的 allocator 替换掉系统的默认 allocator CFAllocatorSetDefault(_myAllocator);
接下来看看 myAlloc 的实现:
void * myAlloc (CFIndex allocSize, CFOptionFlags hint, void *info) { // 做一下线程检查 VERIFY_LISTENER_THREAD();// 实现一个新的 allocation void *newAllocation = CFAllocatorAllocate([UIDeviceListener sharedUIDeviceListener].defaultAllocator, allocSize, hint); // 失败就放过 if (newAllocation == NULL) return newAllocation; // 有东西了,赶紧把新的内容塞进准备好的 allocations 变量里,这是个 C++ 的 std::set<void *> if (hint & __kCFAllocatorGCObjectMemory) { [UIDeviceListener sharedUIDeviceListener].allocations->insert(newAllocation); } return newAllocation;
}
与此同时,通过 KVO 观察 UIDevice 公开的 batteryLevel 属性,接收 KVO 回调:
- (void) observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context { if ([change objectForKey: NSKeyValueChangeNewKey] != nil) { std::set<void *>::iterator it; for (it=_allocations->begin(); it!=_allocations->end(); ++it) { CFAllocatorRef *ptr = (CFAllocatorRef *) (NSUInteger)*it; void * ptrToObject = (void *) ((NSUInteger)*it + sizeof(CFAllocatorRef));if (*ptr == _myAllocator && // Just a sanity check to make sure the first field is a pointer to our allocator [self isValidCFDictionary: ptrToObject]) // Check for valid CFDictionary { CFDictionaryRef dict = (CFDictionaryRef) ptrToObject; if ([self isChargerDictionary: dict]) // Check if this is the charger dictionary { // Found our dictionary. Let's clear the allocations array: _allocations->clear(); // We make a deep copy of the dictionary using the default allocator so we don't // get callbacks when this object and any of its descendents get freed from the // wrong thread: CFDictionaryRef latestDictionary = (CFDictionaryRef) CFPropertyListCreateDeepCopy(_defaultAllocator, dict, kCFPropertyListImmutable); if (latestDictionary != nil) { // Notify that new data is available, but that has to happen on the main thread. // Because of the CFAllocator replacement, we generally shouldn't // do ANYTHING on this thread other than stealing this dictionary from UIDevice... dispatch_sync(dispatch_get_main_queue(), ^{ // Pass ownership of the CFDictionary to the main thread (using ARC): NSDictionary *newPowerDataDictionary = CFBridgingRelease(latestDictionary); [[NSNotificationCenter defaultCenter] postNotificationName:kUIDeviceListenerNewDataNotification object:self userInfo:newPowerDataDictionary]; }); } return; } } } }
}
上面一堆嵌套代码判断了一层又一层,最后做了一个 CFPropertyListCreateDeepCopy 然后通过通知转发出去。
CFDictionaryRef latestDictionary = (CFDictionaryRef) CFPropertyListCreateDeepCopy(_defaultAllocator, dict, kCFPropertyListImmutable);
严格来说这种写法并没有用到私有 API,但是非常取巧。如果内核实现代码不用 default allocator 来取 IORegistry 的信息这里就失效了。事实上从 iOS 10 开始这个做法确实也失效了。但是整个思路非常有趣,值得观摩。
上面我们在 macOS 上通过取 AppleSmartBattery 这个 IOService 可以获得更多电池信息,但是在 iOS 上没有。那么我们还能不能寻找其他的 IOService 看看是否有携带了电池信息的呢?
此文iOS IOKit Browser - Christopher Lyon Anderson 使用私有 API 遍历了 iOS 上所有的 IOService,并且在他的截屏中是包含了电池信息的。我 clone 下来发现已经没有 cycleCount 信息了,但是这个项目有个地方挺有意思:
NSString *bundlePath = [[NSBundle bundleWithPath:@"/System/Library/Frameworks/IOKit.framework"] bundlePath]; NSURL *bundleURL = [NSURL fileURLWithPath:bundlePath]; CFBundleRef cfBundle = CFBundleCreate(kCFAllocatorDefault, (CFURLRef)bundleURL);self.IORegistryGetRootEntryShim = CFBundleGetFunctionPointerForName(cfBundle, CFSTR("IORegistryGetRootEntry"));
先取系统的 IOKit.framework,然后用 CoreFoundation 的接口来取函数指针,然后就可以使用这批 IOKit 的私有函数了。可惜此方法亦已无效。
iOS 方面暂时还未找到能展示 cycleCount 信息的方法,想必 Battery Health App 应该用了更加厉害的黑科技。可能只有越狱逆向一下才知道它是怎么做到的了。
之前因为 sysctl() 的缘故看了一下 XNU 的源码,结果发现内核层还是有不少有意思的东西。IOKit 作为驱动层的 API,除了获取电池信息之外还能干很多事情。
本文通过 IOKit 的简单接口,扩展学习了 XNU 的 IPC 通信机制 mach port。希望后续能通过这些工具做出点有意思的东西来。

在“枫言枫语播客”最近一期节目里,嘉宾的推荐曲目是《东京爱情故事》的主题曲,由小田和正创作和演唱的《突如其来的爱情》。我听完这首歌觉得很赞,想起《东京爱情故事》这个名字经常听到却从未看过,于是找来看了一下。
一开始并没对这部28年前的日剧有什么期待,但看完第一集之后就深陷其中不可自拔。
最终我把这部电视剧看了两遍,部分Episode看了几遍,也把原著漫画看了一遍。虽然这部1991年的电视剧被标为“纯爱”故事,但我却觉得它不只有“纯爱”。这几十年来电视剧、电影、动漫这种形式的发展十分迅速,以爱情为主题的作品也层出不穷,相较之下《东京爱情故事》有其时代的局限性,但她却有一种超越时空的魅力,让人为之着迷,为之倾倒。
<center>以下是剧透分割线,强烈建议还没有看过的读者朋友看完电视剧之后再看以下内容。</center>

电视剧改编自柴门文的同名漫画《东京爱情故事》。因为喜欢此剧的缘故,我把漫画完整看了一遍,不过我觉得漫画并不好看。这其中固有电视剧先入为主的原因,但最重要的是电视剧对原作人物进行了大幅改编,重新塑造了“赤名莉香”这个深受观众喜爱的角色。
电视剧以从乡下(四国爱媛县松山市)初到东京的主角永尾完治在机场见到来接机的公司同事赤名莉香开场。两人同在东京一家小体育用品公司 Heart Sport 工作,完治刚下飞机,还没到公司报道就被莉香拉去仓库搬运货物。这样的快节奏让憨厚的完治对这个陌生与未知的城市产生些许不安。
莉香笑着说:
“就是不知道明天会发生什么事,才会充满希望的不是吗?”
两人由此结缘。后来完治参加同学会,见到从小一起长大的关口里美和三上健一。里美是完治暗恋多年的对象,三上则是里美暗恋多年的对象。这样的三角恋情在今日看来是比较俗套了,再加上莉香看到完治对里美的深情与专一,喜欢上了完治,于是变成了四角。故事就此围绕这四个主要人物展开。

《东京爱情故事》的电视剧版每集约45分钟,一共11集(特别篇不算),要在这个时长里把四卷漫画内容塞进来,势必要进行大幅裁减。编剧坂元裕二对原作的改动很大,主要人物、重要事件和故事主线得到了保留,但是最重要的人物从男主角永尾完治转移到了女主角赤名莉香,并且把女主角的黑暗情节几乎全部进行了改编。最终编剧只保留了女主角与部长过往的婚外情黑历史,但是重塑了一个阳光开朗,坚强执着,大胆超前,淘气可爱的莉香。
莉香的扮演者铃木保奈美对该角色也起到极为重要的作用。1991年的化妆术没有今天那么精致,摄像机也没有自带美颜,画面略带灰蒙,东京街头车灯在胶片上留下长长的痕迹。铃木保奈美的脸型有点长,颧骨处有点外扩,眼睛不算大,眼角稍稍往下,并不是一眼见到就惊为天人的美女。在第一集刚出场的第一幕,我甚至觉得这个角色有点普通,可能是个配角。直到她和完治站在海边,笑着对完治说“正是因为陌生和未知,明天才会充满希望不是吗?”
那笑容就像早晨的阳光,在拉开窗帘的一瞬间灌满房间,让美好的一天涌动着光明与希望。
相比于今天过于完美的技术,当年的影像让观众感到更加真实,也更加亲切。人都是有缺点的,完全没有缺点的人看上去就不太像人,反而会让观众产生距离感。演员平凡的一面让她的笑容更加明亮了。
同时铃木保奈美的声音也似银铃般动人。第一次听到她的声音我还以为在看动漫,以为有专门的声优给她配音。尤其是第二集开场,莉香为了预测天气好坏把鞋子踢上天空(类似硬币正反面)结果卡在树枝上,然后蹦蹦哒哒欲取鞋子而不得的样子,搭配这副嗓音,可以说非常漫画化了。
铃木保奈美自身的条件讨人喜欢,不过人好看声音好听的演员也不少,同剧中扮演女二号里美的有森也实以及女三号长崎尚子的千堂晃穗都很好看,但是为什么莉香这个角色的人气却是全剧最高的呢?
我觉得是演员的条件与角色塑造的完美结合。编剧坂元裕二居功至伟,是他把漫画中黑暗色彩很重的莉香改编成了阳光开朗的人物。
漫画中的莉香小时候在非洲长大,以此解释她的野性和放荡不羁的行为。同时她患有精神疾病,在故事中有遭遇过精神恐慌袭击的情节。在漫画中,莉香和完治工作的地方是一个只有几个人的小公司,莉香和社长有过关系,并且到了后期,在没和完治分手的情况下,跟其他男性有过关系,还怀上了社长的孩子。这几件事情都成为推动漫画发展的重要情节,漫画家柴门文虽然有鼓励莉香对完治的执着而炽烈的情感,但是最终还是让两人分开,莉香在漫画中并不是一个正面的角色。这大概也是我先看完电视剧之后,再看漫画时倍感不适的原因吧。
为了把莉香这个角色变成一个正面阳光的形象,坂元裕二可谓煞费苦心。我不会日语,而年代久远网络上能找到的相关资料不多,虽然有些文章称坂元裕二把自己关在酒店里通宵赶稿,参考演员铃木保奈美的形象做了特别修改云云,但是无从考证。所以这里我们只讨论电视剧成品本身。
电视剧里的莉香,小时候在美国长大,年幼时因为经常转学所以失去过很多同学和朋友。但是每次离开莉香都会笑着告别,这给了她与完治交往过程中令人惊讶的自我治愈一个铺垫。每次完治由于里美的事情忘记赴约、迟到,或者讲错话给她造成的伤害,她总是可以在第二天表现得无关痛痒,依然精气十足地喊一声“丸子!“。这种事情在现实生活中是不可能发生的,实际上莉香也并不是毫不在乎,只是用她那招牌笑容掩盖了自己内心的疼痛。
但是憨厚的乡下小子完治不知道啊,他以为里美所说的就是心里所想的。一开始观众还会有点狐疑,但是来到故事中期,当观众看到她熟练地发出违心的灿烂笑容时,内心所投射的却是她深沉的忧伤,这种鲜明的对比所带来的冲击,要比直白的哭哭啼啼来得更加猛烈。
莉香的笑容太治愈,但笑容的背后太悲痛。所以当铃木保奈美用她那甜美的嗓音阳光灿烂地笑着喊出”丸子!“的昵称时,在不同的场景下观众都会自然地联想到笑容背后的伤痕,以及这种坚强背后的执着。
莉香是许多人想要但在现实中不可得的“爱与希望”。
失去了太多就害怕拥有,因拥有是下一个人失去。这是一种十分消极的态度,遇事选择逃避而不是面对,但省心省力,也因此成为多数人的选择。莉香失去的也多,但是每次离别她都选择笑着面对。这需要不可思议的勇气与精力。所以完治在午夜接到莉香的来电时说:“现在这个时间还精力充沛的,除了你就是便利店了。”
人们喜欢超级英雄,喜欢用超人的能力去弥补现实的遗憾。这个世界是黑暗且残忍的,理想与美好可以给人活下去的勇气。童话故事如此,宗教信仰如此,小说、电影、电视剧皆如此。莉香所拥有的勇气是令人敬佩的,她的毅力与坚持是令人敬佩的,所以在故事后期当她在酒吧里跟三上说“我努力过了,我努力过了啊。”这样泄气的,很不“莉香式勇敢”的话的时候,不知有多少人为之动容。
故事的大结局没有迎来大团圆。莉香在最后关头选择了主动离开。她跟完治说如果你改变主意了就来四点四十八分的列车,但她自己却坐了前一班车走了,在车上痛哭的样子令人心碎。悲剧比喜剧更让人遗憾,更让人断不了念想。假如莉香没有离开,完治在最后一刻赶上了会是怎样?据说曾经引起过观众们的热烈讨论。但是我觉得正是悲剧收场,让观众的内心总有一个不愿释怀的郁结,才成就了这部电视剧,才成就了莉香这个角色,才成就了经久不衰的一部经典。
但凡一个成功的角色,总能让读者愿意为其付出情感投入,因其忧郁而哀伤,为其欢笑而欣喜。为此,恰当合宜的行为很重要。村上春树的人物,悲恸时反而沉默,静静地握着手中的方向盘,漫无目的地开往冬天的北方。寂静的忧伤比嚎啕大哭更能深入人心。
《东京爱情故事》里的人物,难免要在纠缠的感情线中受尽折磨,但不轻易痛哭,甚少哭泣特写,但角色的低语,沉默的表情,指尖的香烟,手中的威士忌,以及因为颤抖而无法握住的话筒,却恰如其分表达了情感的深沉。
四个主角在遇到情感的转折点时各有不同的表现,但相同的是不到最巅峰的时刻不给抱头痛哭的镜头。
女二号关口里美的设定是谨慎软弱,人畜无害的形象。虽有多个掩面而去的镜头,但是哭泣特写很少,与三上分手后也只是坐在地板上靠着墙,沉默无语,面带忧伤。
男一号永尾完治更不必说,只要遇到事情就板着一副苦瓜脸,基本上没有机会流泪。只有在和莉香主动说出分手的那晚,因为颤抖无法握住话筒之后,终于失声痛哭。其他时候那张憨憨的脸上除了因为悲伤的扭曲,还充满对莉香无法理解的困惑。
女主角莉香是坚强的代名词,只在最后大结局一集,主动选择离开完治后,在火车上忆及过往,泪流不止。
这样处理方式与许多动则哭天抢地的电视剧可谓对比鲜明。人类具有共情能力,看到剧中人哭泣会为之动容。但情感的波动也有极限,如果全程都像过山车一样时不时就来要个眼泪那就过分了。而且最重要的是,现实世界并不如此,过度的情感渲染和释放只会让作品远离现实,反而造成距离感,更不容易共情。
在这点上,本剧可谓收放有度。
毕竟是 1991 年拍的电视剧,今天看来还是有些不足之处。
首先是技术类的硬件条件,比如化妆术和摄影技术的进步让当时的成像效果看起来比较普通,然后不知道是经费原因还是时间关系,电视剧的拍摄场地很少,看多了有点情景喜剧的感觉。再者后期制作的时候,一些特写镜头没做自然过渡,比如第一集结尾莉香与完治在代代木公园分开时,莉香喊了三声“丸子”,这时候镜头做了三次放大特写,但是没有任何过渡,就突然啪一下切换过去。第一次看到给我一种运用了恐怖片手法的感觉。 XD
当然,硬件问题会随着技术的发展而解决,所以并不是什么大问题。对于整个电视剧来说,最大的一个不足是在编剧上,重要情节的设计过于碰巧,存在过多偶然。
一个好的小说家,可以把许多碰巧用自然的方式讲述出来。这对电视剧来说也是一样的道理。《东京爱情故事》里有很多偶然和碰巧,囿于时代或者可能经费的局限,整部电视剧拍摄的场景不多。除了大结局去到松山市之外,有10集都在东京,并且只局限于一家酒吧、两家餐厅、一家KTV、办公室以及四个主要人物的家和周边。于是许多事件“碰巧地”在同一时间发生在同一地点碰到最不该碰到的人和事。
比如许多推进故事发展的重要事件都在同一家酒吧发生的。
东京是个很大的城市,现实生活中那么频繁地在街上、在同一家店遇到几乎是不可能的事情。当然其中也有比较合理的设定,比如完治与里美的第一次约会(中间三上自己跑进来变成三个)过程中,完治接到公司电话去处理紧急事件,完成后和莉香一同回到那家酒吧正好隔着一条马路遇上三上吻里美的场面。这个情节有因有果,只有时间是唯一的偶然,合情合理。
但是在这之后,四个主角还是频繁地在这个酒吧,或者在另外一家餐厅偶遇并且由于偶遇发生推动故事发展的事件,那就很勉强了,一边看剧一边难免感受到“编剧之力”在把我拉扯出去。比如三上和里美分手后,完治与三上在一家餐厅谈到一半动手的时候正好遇到来这里吃饭的莉香。东京那么多餐厅,实际出现在这部电视剧里的只有这一家,这样的安排就显得过于巧合了。
还有诸如完治与里美在街头碰面总能被莉香看到,莉香扇出言不逊的同事耳光时又正好被完治看到之类的,完全利用巧合来推进剧情还是比较偷懒的。
但是瑕不掩瑜,况且也没有资料指明当时制作团队有多少时间可以用于修正细节。只要接受了这部剧的设定,好好沉醉在故事里,观看体验还是非常棒的。
其实故事主线的时间跨度很短,完治刚从乡下到东京工作就遇到莉香,几件事情的发生也就三个月而已。三个月,乡下小子还远没有到适应东京大城市的时候,还远没有成长到能理解莉香的内心感受的时候,还远没有到可以处变不惊,从容解决问题的时候。莉香和完治的问题是不对称的,完治面对前卫大胆的莉香完全是手足无措的。
这在电视剧中表现为天真的情节,有时候和情侣间的亲密对话方式表现一致。比如明明面对面却互相用过家家式的打电话方式交流,比如莉香遇到状况的时候不是选择冷静交流而是直接开跑,比如完治遇到出状况的莉香表现为脑内一片空白不知道该说什么做什么。用通俗的一个词来说,这种表现被很多人称为“幼稚”。
感情经验丰富的三上曾对莉香说过:“你的爱对那家伙(永尾完治)来说,可能过于沉重了。”
而在大结局中部长对经过三年成长,已能独当一面的完治说:“现在的你,应该可以很好地接受她的爱了吧。”用另一个通俗的词来说,这种表现被很多人称为“成熟”。曾经我非常反感所谓“成熟”这个词,但是后来我发现我所反感的只是世人所理解的“成熟”,约等于圆滑、世故、违心和奉承。在部长与三上所想要表达的意思中,是完治的尚未成长,不叫“幼稚”,完治的成长,也不叫“成熟”。一个人能够从容冷静地解决问题,不是他表现为从容和冷静,而是他的能力已经成长到可以解决问题的时候了。
完治的内心有对莉香的深爱,但是他分手时说的却是“我没有自信和你继续交往下去”。是“没有自信”,是他不具备解决问题的能力,是他不具备从容应对突发状况的能力,是他没有办法透过莉香阳光般的笑容读懂她内心深藏的孤独。而莉香一直在突发,一直在用笑容掩饰内心的哀伤,一直不愿正面坦然地表达内心的想法。
一个不愿说,一个不理解。人类需要成长,需要随着岁月的沉淀与经验的历练,去增强自己的理解能力,去增强自己换位思考的能力,去波澜不惊地寻找沟通问题的核心与本质。但是成长后的人类啊,却也失去了“哔啵叭,莫西莫西”的天真与纯粹了。
莉香是天真的,是勇敢的,是黑暗的世界行走时投下的灿烂的阳光。
莉香,就是爱与希望。
2019.10.17 凌晨
于自居

今天在学习 macOS 系统的 sysctl() 函数时遇到了一个有意思的东西——EPOCH。遂写此文以记之。
我们知道 macOS(OS X) 系统中有一层核心系统(Core OS)叫做 Darwin。iOS, watchOS 等苹果自家硬件的许多系统都是 Darwin 做的上层开发。所以 iOS 和 macOS 都可以使用 darwin 提供的 sysctl() 函数来获取系统硬件信息,比如 CPU 信息,内存大小之类。
![]()
根据 2006 年的这张系统架构图我们可以看到,Darwin 里面主要包含 System Utilities 和 XNU 内核。XNU 即 X is Not Unix,最早由乔布斯离开苹果后创办的 NeXT 公司开发。XNU 是一个混合内核(hybrid kernel),包含两个部分。FreeBSD 提供了文件系统,网络接口,POSIX 接口等实现,Mach 内核则提供了 IOKit 等硬件驱动接口(在 NeXT 时期叫做 Driver Kit)。
我们在 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 的结果。
在 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_PROC 和 SYSCTL_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_PROC 跟 SYSCTL_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() 我只找到 IOKit 里 IOPlatformExpert.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 不够用的时候不妨到这一层来找找看。

中日两国自古有极深的渊源,文化相近,饮食相似。但是在许多生活细节上又大相径庭。其间差异,不在日本住上一段时间是很难体会的。
本期节目我们邀请到在日本工作的丁宇(@felixding)来聊聊旅居日本这段时间来的工作与生活的体会。
时间线
P.S. 如果你对海外工作与生活的故事感兴趣的话,欢迎收听本播客第13期的节目:在新西兰做程序员是什么样的体验?——枫言枫语播客13期 | 枫言枫语
推荐使用苹果的Podcast App, OverCast, 安卓的Pocket Casts等泛用型播客客户端搜索“枫言枫语”来订阅收听本节目。我们也在国内的荔枝FM和喜马拉雅有同步音源。

本文为《macOS 效率系列》的番外,我们来聊聊 Vim 编辑器及其衍生品的前世今生。
但凡是程序员,无论前端、后台、客户端都应该使用过或者听说 Vim 这款所谓“编辑器中的神器”。多年以来和它的竞争对手 Emacs 一直是经典引战话题。跟“PHP 是最好的语言”、“缩进用 Tab 还是空格”、“大括号要不要换行”之类的战争类似,常在程序员中被人提及。
我没有用过 Emacs 所以本文只谈 Vim。和大多数现代的文本编辑器一诞生就支持 GUI 相反,Vim 是从终端开始的一个编辑器,虽然它也支持 GUI (gVim) 但是喜爱 Vim 用户大都在黑乎乎的命令行里使用它。
Vim 是 vi 编辑器的加强版(Vi IMproved),vi 是几乎所有的类 Unix 系统的标配文本编辑器。读者朋友们若使用过 Linux 操作系统,无论用哪一个发行版,基本都开箱自带 vi。
vi 是一代神人 Bill Joy 开发的,我们在 macOS 效率系列 02: 在终端 Terminal 中运键如飞 提到 csh 也是他写的。关于这位 Sun 公司创始人的传奇故事还有很多,比如拒绝把 BBN 写的 TCP/IP 栈引入 Berkeley Unix 系统,因为他觉得 BBN 的版本太垃圾了,于是自己手搓了一个高性能版本。比如 2000 年他在 Wired 发表了《Why the Future Doesn't Need Us | WIRED》,里面提到未来的人工智能机器人,转基因技术和抗生素滥用等人类的科技发展对于整个地球生态这个复杂系统可能造成的深远影响的担忧。有兴趣的读者朋友不妨读读看。
在电子显示器出现以前,计算机使用打字机来进行人机交互。当时流行的文本编辑器都是所谓的"行编辑器(line editor)"。你通过键盘输入一行命令,然后计算机通过打印机把内容打印到纸上。所以你不可能实时看到所有文本内容,不然刷一下纸张就都打没了。

1970 年代贝尔实验室(Bell Labs)发行的 Unix 系统自带的文本编辑器叫做 ed,也是一个行编辑器,非常不友好。于是来自伦敦玛丽女王大学(Queen Mary University of London)的 George Coulouris 改进了一下写了个改进版叫做 em。
他到伯克利大学的时候给很多人演示了这个编辑器。Bill 对这个东西非常感兴趣,于是要来了代码(装在一个磁带里),和同学 Chuck Haley 一起写了个 em 的改进版叫做 en,然后又拓展了 en 成了 ex(EXtended)。再后来电子显示器开始出现了,Bill 给自己的 ex 编辑写了个全屏版,于是就有了 vi。
现在 Vi 和 Vim 里还保留了 ex 模式,输入 : 就是 ex 模式。常见的命令比如:
:1,2 p // 打印 1-2 行的内容
:1,2 d // 删除 1-2 行的内容
:1,2 m 12 // 把 1-2 行移动到 12 行下面
:1,2 co 12 // 把 1-2 行的内容复制并粘贴到 12 行下面
:= // 显示总行数
显然这些命令都是为了打印机交互而设计的。
Bill 在开发 vi 的时候使用的是 Lear Siegler 公司的 ADM-3A 终端。

他的键盘布局长这样:

看到这里大家应该就能明白为什么 Vim 的键盘设计是 hjkl 为左下上右了,以及为什么 Esc 键那么常用了。按照 ADM-3A 这么紧凑的键盘布局,基本上双手不需要过多移动就能完成大部分工作。
题外:那么为什么现在常用的键盘用是 Tab 键取代了 Esc 键的位置呢?那是因为大 IBM 崛起了 XD

vi 流行起来以后大家都想用这个编辑器,但当时 ex 和 vi 都是 AT&T 的知识产权。如果想在 Unix 以外的平台使用 vi 你就得自己 clone 下来改一波。于是在这期间出现 vi 的许多个衍生版:
1988 年来自荷兰的程序员 Bram Moolenaar 为了在 Amiga 机器上(Commodore International 公司生产的机器。这家公司1954 年成立,曾开发过全球最畅销的台式电脑 Commodore 64,后来在 1994 年破产。)使用 vi 自己移植了一个版本,叫做"Vi IMitation"。直译成中文就是 Vi 的模仿版。后来改名为"Vi IMproved"也就是 Vi 的改进版,移植到了许多其他平台。当时 Vi 在 Unix 平台还是非常受欢迎的编辑器,大家对 Vim 心存疑虑,不知道它的质量是否真的称得上 Vi "改进版"。直到 1992 年 Vim 首次在 Unix 平台发布后迅速蹿升为最流行的文本编辑器。
Vim 项目至今仍在活跃迭代,截止本文发布之日,Vim 最新的版本为 8.1.2052。
| 时间 | 版本特性 |
|---|---|
| 1991 Nov 2 | Vim 1.14: First release (on Fred Fish disk #591). |
| 1992 | Vim 1.22: Port to Unix. Vim now competes with Vi. |
| 1994 Aug 12 | Vim 3.0: Support for multiple buffers and windows. |
| 1996 May 29 | Vim 4.0: Graphical User Interface (largely by Robert Webb). |
| 1998 Feb 19 | Vim 5.0: Syntax coloring/highlighting. |
| 2001 Sep 26 | Vim 6.0: Folding, plugins, vertical split. |
| 2006 May 8 | Vim 7.0: Spell check, omni completion, undo branches, tabs. |
| 2016 Sep 12 | Vim 8.0: Jobs, async I/O, native packages. |
一直以来 Vim 项目都以跨平台的目标在编写,所以代码非常容易被移植。造成的结果就是目前你可以在几乎所有平台上使用 Vim。
从 Vi 到 Vim 我们可以看到这个编辑器有极重的历史痕迹。像是全键盘操作,Terminal Style 的 UI 之类的。对于已经习惯了鼠标操作的现代用户来说,Vim 的学习门槛确实比较高。但是入门以后可以在全平台轻松使用这个优点还是很棒的。
作为一个程序员,除非是纯粹面向 Windows 生态并且不做也不需要了解任何服务器相关开发的人,否则几乎绕不开"终端"这个东西。我相信一个好的程序员不可能没用过终端命令行。
当你设置一台新的服务器时,常见的选择是用 Linux 系统,远程 SSH 登录操作,就算使用 Docker,你也得先配置好 Docker 环境。
当你使用 macOS 系统的时候,GUI 可以满足日常轻量任务,但是跑个脚本自动化,跑个 git 代码管理什么的还是终端更加高效。
而只要你在终端使用文本编辑器,基本就会遇到 Vim。学会 Vim 可以一招鲜吃遍天。这大概是学习 Vim 最大的好处。
至于用 Vim 来取代 IDE 进行工程项目我倒觉得不一定合适。许多现代编辑器和 IDE 都设计得非常优秀,可以帮我们节省大量的时间。iOS 和 Android 开发需要用 Xcode 和 Android Studio 有其特殊性且不提,Sublime Text,Visual Studio Code 都有非常实用的 UI 功能和丰富的插件库。通过鼠标和键盘结合,也能非常高效地完成任务,同时入门的难度也变得很低。
所以虽然日常工作已不再依赖 Vim 编辑器了,但是学习实用 Vim 依然能帮助我们在多种系统间无缝切换。Vim 的键位设计也让用户双手无需离开基本位置就能完全所有任务,这点让许多追求高效的用户十分着迷。于是就有了很多 Vim-like 的插件。
Sublime Text, Visual Studio Code, Xcode 之类的编辑器都有 Vim 操作的插件。可以说只要是个主流编辑器都有人给他加了个 Vim 模式。
不仅编辑器,浏览器也有许多 Vim 的忠实粉丝。比如早在 IE 6 还统治这个世界的时候,Firefox 强大的插件系统就催生了一个非常厉害的项目,叫做 Vimperator。

当时 Chrome 尚未崛起,Firefox 的插件能力还很强大,允许用户把浏览器改成千奇百怪的形态。再结合 Stylish 自定义浏览器的样式,可以实现没有标题栏,没有搜索框,没有 Tab,只有地址栏的全屏体验。使用全键盘操作,无需鼠标参与,受到一众 Geek 的喜爱。
后来 Chrome 开始版本号大战之后,无需 Reload 的插件系统,极其迅速的启动、浏览体验把 Firefox 一举击垮。Firefox 也在版本号大战开始后不久,把插件系统改成类似 Chrome 的开发者友好向,同时也丧失了更加强大的自定义性。
期间 Vimperator 项目团队还因为意见分歧,分裂出了 Tridactyl 项目。Vimperator 在 Firefox 大幅修改插件系统之后宣告结束。Tridactyl 目前还活着,不过我早已转投 Chrome 阵营,没再使用过 Firefox 了。
Chrome 上自然也有类似的插件比如: Vimium。但是由于 Chrome 插件的设计,这东西必须等到网页加载完后才能加载自己的逻辑,所以加载过程中你无法使用 hjkl 来滚动页面。同时 Chrome 插件的能力也相当有限,以前 Vimperator 可以通过 : 命令模式对整个浏览器进行各种操作,可以使用 / 进行当前页面搜索并对可点击区域弹出数字标签然后模拟鼠标点击等等。
介绍如何使用这些类 Vim 体验的插件的文章很多,本文不再赘述,有兴趣的读者朋友可以自行 Google 尝试:
Vim-like 软件还有很多,程序员圈子对 Vim 的热情不减,除了情怀,更多是因为其高效的特性。丰富的插件系统和同样丰富的插件管理器,可以自由 map 的快捷键设置,可以在多个系统共享的 .vimrc 配置文件,以及开源,这些非常 geek 的特质让 Vim 成为程序员们心之向往的一代神器。
P.S. 感谢 @RoCry, @Jake 以及许多小伙伴在过去岁月中的推荐与交流,macOS 效率系列中提到的许多工具都来自和朋友们的交流与讨论

今天凌晨(2019-09-11) 1 点,苹果在 Steve Jobs Theater 举办了秋季发布会,发布了包括 Apple Watch 5, iPhone 11, iPhone 11 Pro, iPhone 11 Pro Max 的多款硬件产品。
发布会结束后网上很多人说这届苹果又没有创新啦什么的。
今天我邀请了本播客的老朋友自力(@hzlzh),从 2019 苹果秋季发布会聊起,谈天说地。
P.S. 节目中提到的系统相机拍摄新交互名为 QuickTake
P.S. 节目中提到的 Arcade 发音为 /ɑr'ked/ 或 /ɑː'keɪd/ 😂
P.S. 经听友提醒,海王和马王是同一个演员 Jason Momoa

苹果原创剧《See》预告片: https://www.apple.com/apple-tv-plus
推荐使用泛用型播客客户端搜索“枫言枫语”来订阅收听本节目。我们也在国内的荔枝FM和喜马拉雅有同步音源。

在上一期节目我们聊到移民就像围城,有些人想移民出去,而移民出去的人又有些想回来。在大公司当社畜也是一样的道理,很多人想尽办法要到大公司上班,而在大公司的人又想着要出去创业。
今天邀请到我们节目的是杭州谜底科技的 61,PriceTag 应用推荐就是他们运营的。谜底科技的团队很小,目前只有 4 个人,但是他们做了非常非常多的事情,实在令人佩服。61 在开始创业生涯之前也在大公司当过程序员,Price Tag 这个项目是他二次创业了。
创业的过程既有追求自由的满足,也有举步维艰的困苦。
让我们一起听听 Price Tag 的创业故事。
推荐使用泛用型播客客户端搜索“枫言枫语”来订阅收听本节目。我们也在国内的荔枝FM和喜马拉雅有同步音源。

这几年经常有朋友到海外去留学、工作和生活,不同的国家有不同的文化和风景。今天邀请到我们节目的是萧宸宇 (iiiyu),一位曾经在国内工作后来移民新西兰的程序员。
移民的过程有幸运的时候也有糟心时候,让我们一起来听听他的移民故事吧。
PS: 节目中嘉宾的推荐曲目是《白日梦蓝》后更正为《火车驶向云外,梦安魂于九霄》
推荐使用泛用型播客客户端搜索“枫言枫语”来订阅收听本节目。我们也在国内的荔枝FM和喜马拉雅有同步音源。

T.i.T 创意园有一家非常赞的小小咖啡店,在她刚开业的时候本节目第四期的嘉宾 Clu 就在 IG 跟我说这家店很棒。于是我很快就被圈粉了。过去几个月的时间里,我几乎每天都过来买咖啡。
店里好看的咖啡师 Tia 小姐姐给我们介绍了各种不同的豆子,有来自荷兰 Keen Coffee 的,来自日本的我已经忘记是什么类型的豆子,还有来自上海 T12 Lab 的 SOE 豆子,这款是我每天必喝的咖啡。
今天非常开心邀请到了这家店的老板黄不了到我们节目来聊天。在 T.i.T 创意园的这家是第二分店,他们最早是在广州的北京路那里开了一家网红店,名为“6 号咖啡”。
让我们一起来听听“6 号咖啡”的故事吧。

推荐使用泛用型播客客户端搜索“枫言枫语”来订阅收听本节目。我们也在国内的荔枝FM和喜马拉雅有同步音源。

欢迎大家留言或者邮件 [email protected] 分享你喜欢的效率工具或者效率技巧。
我们每天使用计算机系统实际上是在调配和运用计算机资源,这些资源包括网络、CPU、内存、磁盘、能量(电池、电源)。资源永远是有限的,虽然现在计算机的运算能力已经很强大了,我们仍然会时不时遇到系统卡顿、卡死、网络不畅等问题。处理这些问题的方式通常是等待、观察,或者粗暴一点直接重启,非常浪费时间。
今天我们来聊聊如何利用 macOS 及其生态中的 App,让我们时刻掌握 macOS 的核心资源状态。

Activity Monitor.app 最早在 Mac OS X v10.3 被引入 Mac 系统,当时是合并了旧版系统里的 Process Viewer 和 CPU Monitor。和隔壁家 Windows 通过 ctrl+alt+delete 唤出的 Task Manager 的功能类似,可以用于查看 CPU/Memory/Network/Engergy/Disk 等资源的占用情况。

选中某个进程亦可进行关闭和查看详情操作。在 CPU Tab 下双击左下角的图像可以弹出详情窗口,用于查看 CPU 负载历史:

不过 Activity Monitor 提供的信息还不够清晰易读,于是 macOS 生态出现了很多优秀的 App 弥补这部分能力的不足。


bjango 出品的 iStat Menus 就是一个非常优秀的 App,可以常驻在 Menu Bar 上。
当我们发现网络下载不符合预期的时候,只要瞥一眼 Menu Bar 上的实时网络速率就知道到底是网络挂了还是带宽被占满了。
当我们用 Xcode 编译一个比较大的项目的时候,我们期望它能吃满 CPU 以节省时间。
当我们在内网拷贝一个大文件的时候,我们既想知道磁盘的读写速率,也要看看网络带宽是否被占满。
只有当我们掌握了这些信息,我们才知道现在 macOS 到底发生了什么事情,而不是遇到问题的时候一脸懵逼,重启解决一切问题。
iStat Menus 是我自己在用的 App,也有一些其他的 App 可以替代部分功能,有兴趣的朋友可以自行 Google 一下。
本效率系列提到的 App 都不是为了给他们打广告,而是希望通过介绍 App 广播一个思维方式,即:找到问题,找到解决问题的方法。工具是会一直进化的,但是发现问题和解决问题的思路是不变的。
iStat Menus 可以解决资源层面的监控,但是当我们在终端里跑一些命令的时候,我们需要指导当前命令的详细日志。比如 npm install 要看看是不是被源科学了,brew update 一般时间很长要看看是不是中间挂了。
这时候我们可以使用类 Unix 系统都有的一个标准参数来解决这个问题,即:
#--verbose 长命令 #-v 短命令
brew update --verbose


这个 option 是经常接触终端者的常识,不过根据我的观察,不清楚这个通用 opt 的人还是比较多的,遂记之。
![]()
官网: https://www.macbartender.com
用上 iStat Menus 之后本就局促的 Menu Bar 变得更加拥挤了,这时候我们需要用 Bartender 来管理整个 Menu Bar 上的所有 icon。
使用这个 App 我们可以按住 cmd + 鼠标左键来移动 icon 的位置,还可以把不常用的 icon 隐藏起来。


当这些被隐藏起来的 icon 有变更的时候,比如说 Dropbox 变成了 loading 状态,它就会临时放到常用列表中展示一下,loading 结束后又会进入隐藏列表。
对于屏幕不大的 MacBook 来说比较有用。
现在媒体文件随便上个 4K 就是几十 GB 的空间占用,如果读者朋友使用相机拍照拍视频的话,很容易我们的机器就被大文件填满。这时候我们需要知道哪些文件占用了多少空间,使用 Mac 上最优秀的动画 App - CleanMyMac 可以自动清理文件。不过他的价格太贵了,大概钱都用在打广告营销上了,完全不值得这个价格。如果读者朋友有使用 Setapp 套餐的话可以用一下,如果没有我不太建议单独购买。
事实上我们只需要知道磁盘上的空间被谁占用得比较多,找到对应的位置,判断一下文件是否可以删除然后清理一下就行。在终端使用 du 命令我们可以递归查看任意文件夹及其子目录的空间占用情况,不过 UI 上不太友好。
使用 Daisy Disk 可以用比较优雅的饼图来展示空间占用情况:

Trial 版无需付费,但不能拖拽删除,启动 App 时需要等待几十秒。不过反正打开这个 App 的频率很少,删除通过终端执行就行了,所以我一直用着 Trial 版本(这样不对🤦♂️😂)。
Setapp 是一个订阅服务,付费后可以在会员周期内免费下载和更新仓库里的 App。包括 iStat Menus, CleanMyMac X, Base, BetterTouchTool, CodeRunner, Ulysses 等多款知名的高价 App 都在上面。通过 Stack Social 上常年打折的 Bundle 购买非常划算: https://stacksocial.com/sales/setapp-1-yr-subscription(不过目前好像 sold out 了,可以留意一下黑五之类的营销活动,一次买几年。)

欢迎大家留言或者邮件 [email protected] 分享你喜欢的效率工具或者效率技巧。
在 macOS 效率系i列 01: 窗口管理 | 枫言枫语 我们简单介绍过 Hammerspoon 这个 App,它可以用来做窗口管理,且已经有许多人编写了自己的窗口管理脚本,只需下载使用即可。
当时我们亦提过,这个东西是一个自动化工具,它能做的事情非常多,不仅仅用于窗口管理。
本系列此前的文章已经介绍过,使用 Alfred 之类的效率工具可以节省大量重复的机械劳动,但是每个工具的设计都是有针对性的。Alfred 也有它不太擅长的场景,比如说它的 UI 完全集中在输入框内,并不适合比较复杂的交互和内容展示。虽然它也有 iTunes Mini Player 这样的 UI:

但是我觉得并不好用。本着“能用机器做的事情就不要自己动手”的想法,今天我们来聊聊如何利用 macOS 上的自动化工具帮助我们自动完成一些相对复杂的工作。

和我们在 macOS 效率系列 03: 全键盘操作的快捷入口 提到的 Spotlight Feature 一样,同在 2004 年的 WWDC 上由乔布斯首次介绍了 Automator 这个 App(
从 1:24:23 开始)。我接触 macOS(OS X) 比较晚,第一次使用的是 OS X 10.6 Snow Leopard。Automator 已经存在好几年了,我第一次使用 Automator 是用来压缩设计师给我的视觉稿。当时 iPhone 4 发布大约一年左右,大家第一次接触 Retina Display,图片资源也需要用 @2x 来区分高清版本。
设计师给我的视觉稿是 2x 大小,我嫌自己把每一张图都缩小太麻烦,即便全程使用快捷键也依然是大量的重复工作。所以用上 Automator 写了一个 Workflow,作为一个Service(现在叫做 Quick Actions 了) 存在 Finder 的 Services 菜单里。以后设计师给我发图我只要全选然后跑一遍 Service 就可以把 2x 图缩成 1x 图并且自带后缀重命名了。
年代久远已经找不到当年写的自动化 Service 了,大概是类似这样的感觉:

可视化编程是 Automator 最大的特点,无需写代码背景,只要使用鼠标拖拽点击就可以操作 Finder, Photos 等多个系统 App。这个来自 Apple Script 团队的作品,把自 1993 年以来随 System 7 发布的 Apple Script 又向前推了一步。
创建好的 Service 和 Workflow 都可以用文件的形式进行分享。不过,还有比 Automator 更好用的可视化编程 App。

官网: https://www.keyboardmaestro.com/main/
最早于 2002 年由开发者 Michael Kamprath 发布的一款可视化编程的自动化工具,比 Automator 出现还要早。2004 年被位于西澳珀斯(Perth)的软件公司 Stairways Software 收购了,并在此基础上不断优化和改进。目前最新的版本是 2018 年 8 月发布的 8.2.4,相信今年 macOS Catalina 发布后这款 App 还有不少辅助功能限制授权的 API 要进行更新。

相比 Automator,Keyboard Maestro 支持更多的系统事件通知,比如通过 WiFi SSID 变更可以驱动一个 Marcro 自动执行。
Keyboard Maestro 也支持把操作入口挂到 Menubar 上自己 icon 的下拉菜单里。

我曾经玩过一阵子 Auto Window Tiling 的 Serivce,叫做 KWM,这是它的项目地址: koekeishiya/kwm: Tiling window manager with focus follows mouse for OSX。因为这个东西是完全命令行操作的,所以当时我在 Keyboard Maestro 的菜单上挂了 KWM 的重启/启动/关闭的快速入口。

它的做法简单粗暴,就是自动把桌面上所有的窗口强行布局成配置文件里选中的 layout。支持通过鼠标加上快捷键来交换窗口位置。但是后来觉得这种强制的做法不太适合我就没再使用了。
如果你没有任何编程基础,也完全不想动脑思考逻辑,那么你还可以使用 Keyboard Maestro 的录制功能。通过你的 UI 动作自动帮你生成对应的 Macro。
创建一个新的 Macro,点击右下角的 Record 按钮即可开始录制:

Keyboard Maestro 支持的触发器(Triggers)和动作(Actions)很多,利用好这些工具可以组合出非常有意思的东西来。
你甚至可以用触发器自动收集完所需信息之后,打开一个浮窗加载网页,把信息通过网页 UI 的方式展示出来:

我们还可以把 Alfred 和 Keyboard Maestro 结合起来用,就看你的想象力了。

官网: http://www.hammerspoon.org/
从官网的记录来看,Hammerspoon 最早的版本是 2014 年发布的 0.9.4,自那以后 Hammerspoon 一直是 0.9.x 的状态在更新。这个开源项目在 Github 上有超过 5k 个 star,发布了五年依然没有宣布 1.0。
项目的主力开发者之一 @cmsj 今年 5 月也在 issue 中讨论这个 App 差不多该发布 1.0 然后往 2.0 前进了。
和上文介绍的可视化编程 App 不同,Hammerspoon 是硬核的真·编程。你需要在用户侧编写 Lua 脚本,这个脚本将跑在 Hammerspoon 提供的 Lua 虚拟机上,通过项目提供的 luaskin 接口,和应用层进行通信,从而只用 Lua 脚本就能使用系统提供的各种能力,包括公开的和私有的 API。
假如读者朋友已经有一定的编程经验但是不熟悉 lua 语言,这里推荐使用 Learn X in Y minutes 来快速上手(亦有中文版: X分钟速成Y)。Lua 相比于其他特性丰富的语言来说要简单得多,很容易学,而且我们在 Hammerspoon 最主要的目的也只是实现自动化替代机械劳动而已,并不需要多么高深的 Lua 功力。

Hammerspoon 的 UI 非常简单,只有一个显示 Log 和可以进行全局设置的窗口,其他所有的配置都放在 ~/.hammerspoon 目录下。
➜ .hammerspoon git:(dev) ls
README.md Spoons hs init.lua
init.lua 是 App 启动后会读取的初始化脚本,也是我们的主战场。通过 require 关键字我们可以引入官方提供的能力或者自己下载的第三方库:
local tiling = require "hs.tiling"
local hotkey = require "hs.hotkey"
local mash = {"ctrl", "alt"}
hotkey.bind(mash, "h", function() tiling.cycleLayout() end)
tiling.set('layouts', {
'gp-vertical', 'columns'
})
上述代码实现了通过快捷键 ctrl+alt+h 对当前 Workspace 下所有窗口进行重排布局,用到的 hs.tiling 来自 https://github.com/dsanson/hs.tiling, hs.hotkey 则是 Hammerspoon 自带的能力。
Hammerspoon 官方文档有所有 API 的说明。基本上 macOS 自带的能力 Hammerspoon 都有覆盖,包括 WiFi 触发器,蓝牙触发器,自带 SQLite,控制 Spotify,控制 iTunes,自定义弹窗,alert,notification 等等。
如果官方提供的能力不能满足你的需求,你还可以自己把 Github 上的代码 clone 下来,自己实现需要的 API。
这里还有个用户贡献的参考配置列表:https://github.com/Hammerspoon/hammerspoon/wiki/Sample-Configurations。下载覆盖后即插即用。
首先是用于窗口管理, ctrl+alt+left/right来实现水平三分屏(大屏幕用),ctrl+cmd+left/right 实现两分屏(MacBook 用)。ctrl+alt+h 实现多列自动布局。
然后是 WiFi Watcher,发现我连上了公司的 WiFi 就自动帮我把 MacBook 静音。
加了一个快捷键自动 Reload Config 方便调试。
当切换到 Finder App 的时候自动激活属于 Finder 的所有窗口,不然默认情况下只会激活最后一个,如果开很多就比较难找到我要的那一个。
目前我只用了这一些,但是 Hammerspoon 的可扩展性非常强,基于它可以实现很多有意思的东西。如果读者朋友具备写代码的能力的话,用脚本实现是比可视化编程更灵活更高效的做法。

欢迎大家留言或者邮件 [email protected] 分享你喜欢的效率工具或者效率技巧。
不管使用哪一个 App 或者操作系统,掌握快捷键永远提高效率最简单的方法。就像它的名字"shortcuts"一样,它的设计就是为了让你节省时间的捷径。
macOS 系统自带许多有用的快捷键,有全局的也有各个 App 自己的。Mac App 的特色是 Menubar 上的菜单如果是重要操作一般都会带有匹配的快捷键,而且实现起来非常简单。所以 macOS 生态里的快捷键非常统一。用户只需要学习一套快捷键操作,就可以复制到其他 App 里面。
大部分使用 macOS 的读者朋友应该都习惯了相当一部的快捷键,但可能还有些有用的快捷键没有用到。我在写作此文的时候也发现有些快捷键之前都不知道,整理的过程也是我学习的过程。
本文整理 macOS 几个大类的快捷键,希望能对读者朋友们有所帮助。更多主流 App 快捷键大家随时可以通过 Google 关键字: App Name + Shortcuts 或者是 App Name + Cheatsheat 来找到。
系统自带的快捷键有一部分可以从 System Preferences -> Keyboard -> Shortcuts 里面直接找到:

这里我们介绍几个比较常用到的全局快捷键:
| Key | Action |
|---|---|
| cmd + tab | 在已经打开的 App 之间进行切换,按住 shift 可以反向选择 |
| ctrl + left/right | 左右切换 Workspace |
| ctrl + up | 显示 Mission Control |
| ctrl + down | 显示当前 App 所有 Windows |
| option + cmd + l | 在 Finder 打开 Downloads 目录 |
| option + cmd + d | 显示/隐藏 Dock |
| option + cmd + esc | 打开 Force Quit App 列表 |
| shift + option + cmd + esc | 直接 Force Quit 当前 App |
即使有上一篇文章介绍的 Alfred我也经常使用这些快捷键,比键入关键字搜索要快。当我同时需要打开多个应用或者多个窗口时也配合第一篇关于窗口管理的工具进行多窗口排布,可以大大提高工作效率。
| Key | Action |
|---|---|
| cmd + ` | 在当前 App 的多个窗口直接切换 |
| cmd + m | 最小化当前窗口 |
| cmd + h | 隐藏当前 App |
| cmd + opt + h | 隐藏除了当前 App 以外的所有窗口 |
| cmd + ctrl + f | 进入/退出最大化 |
| cmd + w | 关掉当前窗口或者当前 Tab |
| cmd + opt + w | 关掉当前 App 的所有窗口 |
对于开发者或者文字工作者,每天都在敲键盘打开,能够不用鼠标就完成选择文本、替换文本之类的操作是最好的。以下是文本编辑常用的快捷键:
| Key | Action |
|---|---|
| ctrl + a | 跳到当前行的最前面 |
| ctrl + e | 跳到当前行的最后面 |
| cmd + left/right | 跳转到当前行的开头/结尾 |
| cmd + up/down | 跳转到当前文档的最顶部或最底部 |
| cmd + del | 删掉当前光标到行首之间的所有文本 |
| shift + up/down/left/right | 按住 shift 和上下左右可以选中文本 |
| 鼠标点击某处,再按住 shift 点击另外一处 | 可以直接选中两次点击之间的所有文本 |
| alt + left/right | 向左/右跳一个单词 |
| 双击鼠标 | 选中最靠近的一个单词 |
| 三击鼠标 | 选中当前行 |
当我们按下 shift 之后我们就可以通过上下左右方向键来选择文本。这时候按住 cmd + 方向键的效果就带上了选择效果。比如 cmd + left 本来是跳转到光标所在行的最前面,加上了 shift 就自带了选中效果。
所以如果你想选择当前光标所在的位置到文本最底部,按住 shift + cmd + down 就可以了,非常方便。
这里的文本编辑快捷键是针对通用的文本编辑器而言的,喜欢 Vim/Emacs 的朋友也可以安装对应的插件,实现更加高效的操作。
比如在 Vim 里面,跳转到文档最顶部只需键入 gg 即可。选中当前光标到最顶部则只需 vgg,理论上是要更加省时间的做法。只是学习曲线要高得多,如非码农,一般也用不上。
| Key | Action |
|---|---|
| shift + cmd + 3 | 截当前全屏并存文件到桌面 |
| ctrl + shift + cmd + 3 | 截当前全屏并保存到剪贴板 |
| shift + cmd + 4 | 按下后拖拽鼠标选择截图区域,或者按下 space 直接截取当前窗口并保存到文件 |
| ctrl + shift + cmd + 4 | 按下后拖拽鼠标选择截图区域,或者按下 space 直接截取当前窗口并保存到剪贴板 |
系统自带的截图已经非常之好用,但是藏得比较深。绝大多数人都使用第三方 App 来截图我觉得也无可厚非。
我自己一般只用系统截图,只有需要对图片进行标注时会使用第三方 App。一般用: Annotate - Capture and Share on the Mac App Store
![]()
它的快捷键和系统的一致,我无需再学一套快捷键,截图后会弹出主窗口用于编辑。
| Key | Action |
|---|---|
| cmd + , | 打开当前 App 的 Preferences 窗口 |
| cmd + w | 关闭当前窗口 |
| cmd + 1/2/3 | 选中当前第 n 个窗口 |
| ctrl + tab | 切换到下一个窗口 |
| shift + ctrl + tab | 切换到上一个窗口 |
| cmd + l | 在 Safari/Chrome 中直接选中地址栏 |
| cmd + r | 在 Safari/Chrome 中刷新当前页 |
| cmd + f | 大部分支持搜索的 App 的搜索快捷键 |
| cmd + opt + f | VSCode/Sublime 之类的 App 可以支持完整 Workspace 搜索 |
| cmd + t | 大部分多 Tab 应用支持以此创建新 Tab |
| cmd + shift + [ | 大部分多 Tab 应用支持以此往左切换 tab |
| cmd + shift + [ | 大部分多 Tab 应用支持以此往右切换 tab |
| cmd + shift + n | 在 Finder 中创建新文件夹 |
Apple 官方有一份非常详尽的快捷键列表,有兴趣的朋友可以到这里查看: Mac keyboard shortcuts - Apple Support
开发一个带 UI 的 Mac App 一般都会有一个 MainMenu 的 Storyboard,这个东西在创建工程的时候由 Xcode 生成,自带大量官方的操作和快捷键。开发者开发 Mac App 的时候只需往菜单上增加快捷键和对应的 Action 就能实现 App 内快捷键了,非常方便。有一个叫做 CheatSheet 的 App 就是遍历当前 App 的所有 menu 然后把得到的快捷键列出来给你。

我们即使不用这个 App,自己点一下菜单也能知道大部分的快捷键。对于常用的 App 和操作,我觉得花几秒钟时间学习一下是非常划算的投资。
至于主流 App,通常只需 Google App Name + Shortcuts 或者是 App Name + Cheatsheat 就可以得到别人整理好的列表。
有些比较重的 Web App 比如 Trello 也是自带了大量快捷键。但是大家的快捷键都不是瞎定的,比如 Trello 的许多操作都和 Vim 相仿(通过按下 ? 可以看到 Trello 的全部快捷键)。所以只要学会了一套快捷键逻辑,基本上在多数 App 都是可以复刻的,这也是 Mac 生态非常优秀的地方。

欢迎大家留言或者邮件 [email protected] 分享你喜欢的效率工具或者效率技巧。
macOS 系统提供了 Dock, LaunchPad 这样的工具让用户可以方便地打开各种 App。

同时 macOS 系统还带了一个叫做 Spotlight 的工具,大概长这个样子:

在
上乔帮主第一次介绍了 Spotlight 搜索,入口在 Finder 的右上角,主打是超快的全局文件、邮件、通讯录等数据的搜索。并随着第二年(2005)的 OS X Tiger(10.4) 发布。
如果读者朋友正在使用最近版本的 macOS(比如 macOS Mojave 10.14),那么 Spotlight 已经足够好用了,可以作为快捷计算器,可以快速搜索整台 Mac 上的任意文件。也可以直接用于 Google Search,只需在 Spotlight 上输入需要搜索的词:

然后按下 cmd+B,就会直接在你的默认浏览器里打开 Google 并进行搜索。
l
对于每日工作需要双手打字的开发者或文字工作者来说,让其中一只手离开键盘去抓住鼠标,找到指针在哪里,然后定位到目标位置按下左键,最后在回到键盘上。这个过程是非常浪费时间的,做一次两次无所谓,但是当我的主要工作都是双手打字的时候,鼠标就成了一种分心和累赘。
即使是更多依赖鼠标的设计师们,常用的 Photoshop,Sketch 等多个主流设计 App,也是尽量让快捷键能够被一手完成,不需要让另一只手离开鼠标过来辅助。
所以让双手尽量保持在应有的位置是高效的秘诀。不管是文字工作者还是开发者,Google 是每天都要进行不下数百次的操作。能够双手不离开键盘完成无疑是效率的保证。
Spotlight 在最近几次 macOS 的更新中已经变得越来越好用,那么还有没有比他更好用的全键盘操作工具呢?
Spotlight 从 OS X 10.4 发布到今天的变化明显受到来自 macOS 生态中许多优秀 App 的影响。
Quicksilver 就是比较早做快捷入口的 App 之一。
默认使用 ctrl+space 呼出,输入任意关键字即可进行全局搜索:

同时它还支持多种系统事件 Triggers,支持 Plugins:

同时它也在 Github 上开源。不过该项目已经远不如当年活跃,目前最主流的全键盘输入/输入工具是——Alfred。

Alfred App 本身免费,如果想用它的 Powerpack 则需要额外购买 License。免费版的 Alfred 已经支持许多实用的功能。首先全局搜索应用、文件、日历等信息是基本功能无需赘述。这里介绍几个非常实用的功能。

系统剪贴板 cmd+c/cmd+v 的操作现在已经成为所有用户的基础技能。但是默认情况下剪贴板只会保留最后一次复制的结果,当我需要复制多个数据,或者记得好像五分钟前复制了一个东西但是已经没了的时候,剪贴板历史记录非常有用。
按下默认快捷键 option+cmd+c,Alfred 会显示你的剪贴板历史记录。支持图片,文本,和文件列表。你可以在设置里面选择保留时长,也可以主动清理缓存结果。
对于经常需要回过头去重新复制一遍的情况,有一个完整的列表帮助我们节省了大量的时间,可以说是不可或缺的一个能力。
如果你不使用 Alfred,有些其他的 App 也实现了同样的功能。比如: Paste - The new way to copy and paste on Mac and iOS

除了保留历史记录之外 Paste 还自带一个 Universal Clipboard,可以在 iOS 上同步 Mac 上复制的东西。不过我个人连系统自带的 Universal Clipboard 都不怎么用,所以只用 Alfred 就足够了。

Alfred 支持快速使用系统命令,比如关机、重启、清空回收站、启动屏保。
我个人最常用的是输入 em 两个字母之后自动匹配第一个选择,清空垃圾桶。

这里介绍一个小技巧,对于在开放式办公室工作的读者朋友可能有点用。平时我们用着自己的 Mac 工作时很多内容是相对敏感的,如果这时候想起身去个洗手间,有的人可能会选择 Lock 一下屏幕,回来后再密码或者指纹解锁,继续工作。
我一般选择打开屏幕保护程序,用的是 IG 网红壁纸 fliqlo。

那密码保护怎么办?在 System Preferences -> Security & Privacy 里面我们是可以设置屏保和 Sleep 之后多久要求输入密码的。这里我设置为 immediately。

Alfred 可以进行非常快速的 Google 搜索。Alfred 的绝大部分操作都是进行命令匹配,类似我们在终端命令行的使用体验。但是通常情况下我们要放进 Google 搜索的内容都不会 Match 到命令,这时候就会触发 Alfred 的默认行为:Search。

你可以在 Preferences 中设置默认的搜索引擎,或者进行添加、删除、修改等操作。如果真遇到我的搜索关键词和命令重复了,我才会在前面加上关键字,比如:
google shutdown
我们每天都要进行成百上千次的 Google 搜索,能够把这个过程的时间节省一点,累积起来还是比较客观的。
Alfred 还支持计算器,也是非常有用的功能,简单计算直接快捷键呼出然后就得到结果了。

Alfred 也支持查词典,用的是系统的词典 App。

不过词典这个东西好不好用得看词典本身,我比较常用的而是欧陆词典

词库比较全,支持结果的网络 wiki 搜搜,支持在 Menubar 上使用全局快捷键快速查找,非常方便。
以上 Alfred 自带的 Features 已经让这个工具足够应付许多场景了,但是它真正好用的地方还在于丰富的插件库——Workflows。
我比较常用的 workflow 是:

直接在 Alfred 里搜索并杀死某个进程,比起 option+cmd+esc,我比较喜欢这种方式。
只是把屏幕关闭,但是不进入 Sleep 状态,保持网络连接和各种 App 的后台活动。通过快捷键触发,在我不想屏保的时候我会选择用这个。
输入 fi 或者 if 可以在 iTerm 中打开 Finder 当前文件夹,或者在 Finder 中打开 iTerm 当前文件夹。
Alfred 的生态很好,搜索 Alfred Workflows 你可以找到许多非常有用的东西。
Powerpack 分为 Single 和 Mega 两种(以前还有 Family),如果你用得多的话我建议买 Mega,只要升级两次就赚回来了。
Alfred 是目前最流行好用的效率工具之一,但总有一天会有比它更好用的东西出现。这也是很多人鄙视写工具介绍类的文章的原因。
“macOS 效率系列”也不打算作为一个“介绍”来写,而是希望之前没有想过抛弃“重复机械劳动”这件事情的朋友们如果看到可以有一些思维方式的启发。
工具是为人服务的,人不会因为用了什么工具而变得更加高级或者更加优越。所以如今到处存在的各种“鄙视链”我们当做段子看看就好,拿它当真就非常可笑了。
介绍 Alfred 和其他的效率工具是有意义的。以前公司有一段时间实行内外网分离,所有的员工每天都需要进行多次内外网切换的操作来实现无阻碍查资料和拉取、提交代码这些事情。
我们当然可以通过鼠标点击右上角 WiFi 按钮,从列表中找到需要的 SSID,单击,等待网络连接成功,然后再去做自己该做的事情。
但是这样的事情做多几次之后就应该要有一种思维:我不想做重复机械劳动。
当时有一小部分同事(当然也包括我自己)使用 Alfred 或者直接用 Apple Script 来完成这件事情,只需要非常简单的快捷键触发,网络就切换好了。
但是从绝对人数来看,这样的人还是占了非常非常少的比例。这是非常正常的,但是我也相信这 90% 手动切换网络的人里面也有相当一部分人不知道有更快捷的方式。一旦他们知道他们也会使用更高效的方法。
所以科普类的文章,介绍效率工具的文章我一直觉得是非常有用的,并不是每一个人都可以只看接口和文档就知道如何使用一个足够复杂的工具,更不用说连这个工具都没听说过的人了。
macOS 生态中有非常多实用的效率工具,也非常容易通过搜索找到。本文只是总结一二,也并不一定适合所有人,但是依然希望能对读者朋友们有一点点的帮助,那么这个系列的目的就达到了。

欢迎大家留言或者邮件 [email protected] 分享你喜欢的效率工具或者效率技巧。
光阴荏苒,日月如梭。
如何把有限的时间和精力用在有意义的事情上是人类无法逃避的问题。作为 macOS daily user,高效用好 macOS 系统及其生态中的各种工具可以让我们少浪费一些生命在无意义的重复劳动中。
关于 macOS 的效率工具网络上已经有非常多的文章可供参考,本系列不过总结一二,希望能对读者有所帮助。

终端(computer terminal)是一个历史遗留下来的名词,早期的终端机大概长这样。这个不是个人电脑,只是一台显示器,接了一个键盘(DEC VT100)。背后有一台巨大的主机,对接很多台终端,这样每个终端就是一个输入输出设备。
今天大家都在用个人电脑(PC),普通用户基本不会遇到多终端共享同一个主机的情况。但也并不是完全没有终端,比如车站的刷卡机就是一台终端机。

所以现在各种类 Unix 系统——比如 Linux 的各种发行版,和基于 FreeBSD 的 macOS——都在 UI 层虚拟了一个终端。
当你打开虚拟终端的时候,和古老的物理终端一样,会运行一个最基本的人机交互程序,称为 Shell。
在 Shell 中输入一行命令,它就解释执行这一行,然后显示结果。另外 Shell 也支持批量处理命令,需要写一个 Shell 脚本,然后执行它。历史上有多种不同的 Shell:
sh: Bourne Shell,由 Steve Bourne 开发,绝大多数 UNIX 系统标配csh: Bill Joy 开发,BSD UNIX 系统标配ksh: David Korn 开发,兼容 sh,并支持 csh 的新功能tcsh: csh 的加强版bash: Bourne Again Shell,GNU 开发的,基本上是所有 Linux 系统默认的 shell 了想要知道你的系统自带了什么 shell,你可以在终端运行:
cat /etc/shells
macOS 自带的是这些:
/bin/bash
/bin/csh
/bin/ksh
/bin/sh
/bin/tcsh
除开 Windows 的 cmd.exe,我最早接触终端是在 Linux 系统上,当时用的是 Ubuntu 桌面版。现在大多数人接触 PC 都是从桌面版带 GUI 的操作系统开始,所以相比之下命令行要显得稍微难操作一点。如果有读者朋友觉得命令行操作起来相对困难的话,可以参考著名的《鸟哥的Linux私房菜》这本书。作为入门读物还是非常容易上手的。
接下来我们假定读者朋友们都已经比较熟悉 Shell 操作了。目前多数类 Unix 系统的默认 Shell 都是 Bash,它已经足够用了,但是还不够好用。
目前比较流行的 Shell 是 zsh 和 fish。二者各有优劣,我个人使用的而是 zsh。
ZSH 称为 Z shell,是基于 sh(Bourne shell)的扩展,包括了 ksh, Bash和 tcsh等多种 shell 的特性。今年 WWDC 2019 上苹果亦宣布将用 zsh 取代 bash 称为 macOS Catalina 的默认 shell。
zsh 的自动拼写纠错,大小写不敏感的自动补全,自带的 where 命令等都非常实用。在 macOS 上用 Bash 试图 cd down[Tab] 的人都应该有所体会。
zsh 还支持各种插件和扩展,开发者可以基于 zsh 实现各种酷炫的命令行体验。Oh My Zsh就是一个开源的项目,也是大多数 zsh 用户的选择。
超过 1350+ 的贡献者为这个项目贡献了 250+ 的插件和 125+ 的主题。
安装起来非常简单,通过 curl 或者按照官网指示安装即可:
sh -c "$(curl -fsSL https://raw.github.com/robbyrussell/oh-my-zsh/master/tools/install.sh)"
oh my zsh 自带的 git 插件简直是开发者的福音。

还有多款主题任君选择: Themes · robbyrussell/oh-my-zsh Wiki
在 shell 里进行文件夹路径变换我们用的是 cd 命令(change directory)。假如我们有一个层级比较深的路径: ~/Documents/ADir/BDir/CDir/DDir。一般我们需要
cd ~ #或者 cd
cd Documents
cd ADir
cd BDir
cd CDir
cd DDir
这还是我能记得这个路径的前提下。一般情况下我只记得它大概在哪里,所以还得多次 ls 看看是不是在当前目录。
这时候你需要一个无论身处哪个路径都可以轻松跳转过去的 jump 操作。
j DDir
这就是 autojump 干的事情。只要 j keyword 就可以到达关键词所在的地方,都不需要记得完整的名字。
autojump 会对你曾经到达过的路径做一个缓存,然后自己计算一个优先级。如果有多个同名文件夹,它会优先匹配最高优先级的那个。如果你觉得这个优先级不对,你可以使用 j -i 或者 j -d 来增加/减少当前目录的权重。
oh my zsh 也提供一个类似效果的插件,叫做 z。github 地址在这里: oh-my-zsh/plugins/z at master · robbyrussell/oh-my-zsh
oh my zsh 除了直接把当前 git 仓库的信息展示在终端之外,还自带许多方便的 git 命令的 aliases,比如:
| Alias | Command |
|---|---|
| gaa | git add --all |
| ggp | git push origin $(current_branch) |
这些 git 命令我们每天都需要打无数次,能用 alias 还是非常方便省事的。
完整的 aliases cheatsheet 可以参考这里: Cheatsheet · robbyrussell/oh-my-zsh Wiki
如果我们不做任何修改,登录 shell 之后的所有配置就是默认的。但是我们通常都需要安装各种工具,这些工具需要前置配置,比如环境变量 PATH。这些东西都保存在 .profile 文件里。类 Unix 系统在登录 shell 的时候一般都会读取 .profile 文件。
如果你在自己的 Home 目录(~) ls -a 一下一般可以看到这几个文件:
.profile
.bash_profile
# 如果装了 zsh 的话
.zshrc
这几个文件的作用是类似的,但是读取顺序不同,其中 .zshrc 只会被 zsh 读取。关于这几个配置文件的顺序大家感兴趣的可以参考这里: .bash_profile vs .bashrc - Abhinav korpal - Medium
一般情况下我会在 Dropbox 里放一个 .bash_profile 文件,然后在多台 Mac 之间共享这个文件,只需使用 ln -s 在 Home 目录创建一个软链。
.bash_profile -> /Users/xxx/Dropbox/bash_profile
这个文件里面我们可以配置 shell 的 locale 信息,比如:
export LANG=en_US.UTF-8
export LANGUAGE=en_US.UTF-8
export LC_ALL=en_US.UTF-8
或者是 Go 的配置:
# Go
export GOPATH=$HOME
export GOPATH=$HOME/go
export GOBIN=$HOME/go/bin
或者是一些常用的但是比较长的命令的 alias:
alias jcs="codesign -dvvv --entitlements :-"
甚至可以写一个 shell 函数进来:
unproxy() {
unset http_proxy
unset https_proxy
echo "Proxy Unset."
}
这些写进 .profile 文件的东西在登录后,在任意路径下直接使用。把常用命令放进来是一个不错的选择。
通常情况下修改完 .profile 之后我们都需要 source 一下让它生效:
source ~/.profile
那么 source 命令和直接执行它有什么区别呢?感兴趣的读者朋友可以看看这一篇文章:Linux下source命令详解 - 在努力! - CSDN博客
简单来说就是使用 sh filename 与 ./filename 来执行脚本的时候,shell 本身会创建一个子 shell 来执行这个脚本。所以在子 shell 里的变量并不会影响到当前的这个父 shell。而 source 执行的时候,并不会创建子 shell 而是直接就在当前 shell 执行了,所以这里面什么 export, alias之类的东西就直接在当前 shell 生效了。

macOS 自带的 Terminal.app 已经足够好用,虽然默认是白色底的,字体也比较小,但是你可以在 Preferences 里面修改 Profiles 和字体。在 macOS Mojave 以上你还可以使用 Dark Mode 来让它变成黑色的。
不过还有比他更好用的终端 App — iTerm2。
iTerm2 有很多非常实用的 Feature,我最常用的有几个:

iTerm2 可自定义的外观项比 Terminal 的多,而且还挺酷炫的。最近更新的版本还可以在顶部加入 Status Bar 显示 CPU/Mem 以及当前路径的信息。虽然我不怎么用但是可以看到 iTerm2 的进化速度比 Terminal 快多了。一旦习惯了 iTerm2 的 UI 就回不去了。
实用的分屏功能
Terminal 虽然也自带分屏和 Tab,但是 iTerm2 可以通过 cmd+d 和 cmd+w 来新建/关闭当前分屏,通过 cmd+[ 和 cmd+] 来左右切换分屏。当我需要多任务操作的时候非常有用。比如我在左边看着本地的目录,右边通过 ssh 登录服务器对着服务器的目录,两边做数据交换。

随时用全局快捷键呼出的 Hotkey Window

在 Preferences 里面选择 Keys -> HotKey -> Create a Dedicated Hotkey Window… 之后可以创建一个专门对应全局快捷键的终端窗口。按下自定义快捷键之后就会 slide in。虽然我自己用的不多但这是 iTerm2 挺受欢迎的一个特性。

实用的小细节
cmd 使用鼠标打开链接iTerm2 还有许多有用的小细节,在日常使用的时候不会觉得有什么了不起,但是当你到别人的机器上用什么都没改过的 Terminal 的时候忽然就发现:我擦,怎么这么难用?这就是好的设计应该有的感觉。
大部分终端 App 都会支持一些非常实用的快捷键操作。我们无论使用哪一个 App,只要是经常用的 App,经常用的动作,都应该学习使用快捷键。
在终端中我比较常用的几个快捷键有:
| Key | Action |
|---|---|
| alt+left | 往左跳一个单词 |
| alt+right | 往右跳一个单词 |
| ctrl+w | 向左删掉一个单词 |
| ctrl+u | 删掉一整行 |
| ctrl+l | clear 整个终端的输出 |
| cmd+enter | iTerm2 的全屏/取消全屏快捷键 |
不同的终端 App 设置的快捷键可能不一样但都大同小异,完整的 iTerm2 Cheat Sheet 在这里大家可以参考一下: iTerm2 Cheat Sheet - Kapeli
有一个叫做 CheatSheet 的 App 可以长按 ⌘ 来查看当前 App 的所有快捷键,不过体验并不是很好。一般我会选择 Google: App + Cheat Sheet 来获得该 App 的 Cheat Sheet 列表。
比如 Tower 就整理了一份不错的 Xcode Cheat Sheet 在这里: Xcode Cheat Sheet。对于 iOS 开发者来说,每天面对 Xcode 的时间贼多,不掌握点快捷键怎么可以呢?


欢迎大家留言或者邮件 [email protected] 分享你喜欢的效率工具或者效率技巧。
最近做了一次关于效率工具的小分享,工具类的东西,文章简介外加实际上手的效果要大于现场演讲,遂动此念以记之。
光阴荏苒,日月如梭。有限的时间与精力是每个人每天都要面对的问题。不同的人有不同的选择,绝大多数人在找到一个能用的方法之后就不会再费心思去寻找更加高效的途径。这大概是人类设计的缺陷,为了节能而紧闭窗户。
这个缺陷带来的后果就是本来就稀缺的时间与精力由于不采用更加高效的方法去解决问题而变得更加稀缺。
“macOS 效率系列”将介绍在 macOS 系统上实用的工具来帮助读者朋友们提高解决问题的效率。
工具只是表象,其背后是对高效的追求,是对重复机械劳动的不屑,是使用工具来进行自我增强。

1968 年斯坦利·库布里克执导的电影《2001太空漫遊》(2001: A Space Odyssey)是名留影史的佳作。其中有一幕是两拨原始的人猿部落为了争抢有限的水和食物等资源经常互相斗殴。这种情况下身体强壮的人猿就比较容易取胜。某天,一只身体瘦弱的人猿在巨兽的尸骨旁边捡到一根骨头。他尝试着敲击巨兽的头骨,发现坚硬的头骨居然能被打破——他发现了使用工具来自我增强的方法。于是他把这个想法分享给了部落的其他人猿。在下一次部落冲突的时候,手持巨兽骨头的人猿把对方打得屁滚尿流。这就是工具的力量。
我们在生活与工作中经常会遇到重复的机械劳动,有的人习惯了一直重复,有的人则选择使用工具或者制作工具,让工具和机器替自己去重复。
我想很多人其实并不是不想提升效率,只是不知如何下手,让我们通过"macOS 效率系列"抛砖引玉,希望能帮到想要高效工作的读者朋友们。
Windows 操作系统自带的窗口管理做得不错,支持鼠标拖拽吸附左右以及 Win + left,Win + right等快捷键。我们经常需要一边开着文本编辑器或者其他工具,另一边开着浏览器查资料。这种时候左右窗口布局就是非常好的辅助。
macOS 系统原生并未支持此项功能,但是有不少优秀的 App 可以实现“窗口管理”(Window Managment)。
![]()
官网: Magnet – Window manager for Mac
Magnet for Mac 可以实现像 Windows 操作系统一样的体验,把窗口拖拽到左右边缘即可吸附并放大填满半个屏幕。同时也支持多个快捷键和多种布局,比如上下布局和四格布局。
Magnet 是我曾经常用的窗口管理工具,但是向上拖动把窗口放大的时候经常触发 macOS 自己的多 workspace 预览功能,有点烦,后来改用 Hammerspoon 就没再使用这个 App 了。
可以在 Mac App Store 购买
![]()
Many Tricks 出品的窗口管理工具。
特色是当鼠标在窗口左上角红绿灯那里悬停的时候可以选择左右上下的自动贴边布局。

同时也支持快捷键操作,也支持自定义布局。

价格 $10,可以在官网购买和下载体验版本。

Slate 是一个开源项目,GitHub 地址: jigish/slate: A window management application (replacement for Divvy/SizeUp/ShiftIt)
Slate 基本上没什么 UI,所有的自定义通过配置文件来实现,默认的配置文件在GitHub这里。
可以在 GitHub Page 上直接下载 .dmg或者下载 .tar.gz文件。
配置文件的格式大概是这样的:
# Push Bindings
bind right:ctrl;cmd push right bar-resize:screenSizeX/3
bind left:ctrl;cmd push left bar-resize:screenSizeX/3
bind up:ctrl;cmd push up bar-resize:screenSizeY/2
bind down:ctrl;cmd push down bar-resize:screenSizeY/2
同时 Slate 还支持用 JS 文件来实现更加动态和复杂的配置,只需创建 ~/.slate.js 即可。
JS 配置文件的语法与文档可以参考这里。

官网: http://www.hammerspoon.org/
GitHub 地址: https://github.com/Hammerspoon/hammerspoon
Hammerspoon 是我目前在使用的工具,它其实是一个自动化工具,可以做的事情非常多,窗口管理只是其中的一个。
Hammerspoon 本身是跑在 macOS 上的一个 App,通过 Lua 虚拟机可以运行 Lua 脚本。用户通过 Lua 脚本和 Hammerspoon App 之间进行通信。Hammerspoon 提供了许多系统能力,以 Lua API 的形式暴露给用户脚本层。
Hammerspoon 本身是开源的的,现在也有许多基于 Hammerspoon API 实现的开源组件可供调用,所以我们只需短短几行代码,就可以实现非常酷炫的窗口布局。
local tiling = require "hs.tiling" local hotkey = require "hs.hotkey"
hotkey.bind({"ctrl", "alt"}, "h", function() tiling.cycleLayout() end) tiling.set('layouts', { 'gp-vertical', 'columns' })
可以通过官网下载安装,然后创建初始化脚本 ~/.hammerspoon/init.lua。参考一下官网的Getting Started文档和Hammerspoon Docs,就可以愉快地实现自动化了。
工具只是表象,最重要的其实是寻找高效方法的思维方式。现在有不少社区或者自媒体会分享各种效率工具,想要找到这些东西并不难,本系列不过总结一二,希望能对读者有所帮助。

主播: 徐涛
嘉宾: 钟颖 | 枫影 Justin Yan
Apple WWDC 2019 于美国时间 6 月 3 日在 San Jose 举办。今年我有幸以 Developer 的身份到 WWDC 现场参加。2016 年我曾经参加过一次不过那时候只是在这边出差顺便去看了一眼。
今年苹果诚意满满,不仅在硬件上给出了性能怪兽,价格突破天际的 Mac Pro,还带来了 UIKit for Mac 以及跨(Apple)平台的 SwiftUI 等多项让开发者非常惊喜的技术。
这次在湾区我见了几位老朋友也认识了一些新朋友。朋友 Louis 跟我说想介绍我给《声东击西》的主播,我可是《声东击西》的听众啊😂。于是非常荣幸地做客播客《硅谷早知道》节目,和同来参加 WWDC 的国内知名 iOS 开发者钟颖一起录制了一期节目,从开发者的角度谈谈今年 WWDC 的一些看法。

今天早上(2019-04-16)各大社交平台都在发巴黎圣母院大火的事情。很多人在微信朋友圈发自己以前去巴黎圣母院时拍下的照片,微博也有 #我和巴黎圣母院# 这样的话题供大家发自拍。
我刚看到第一个发照片时有些不明所以,但是看到多个人都在发巴黎圣母院的时候,我第一时间 Google 了一下相关新闻,才知道圣母院大火的事情。这让我想起去年 9 月巴西国家博物馆的火灾。
巴西国家博物馆的主体建筑有 200 年的历史,曾是葡萄牙王室的行宫。1889 年新成立的共和政府将其改造成博物馆,并向普通民众开放。馆藏据说有 2000 万件,包括有上万年历史的南美最早人骨化石“卢西亚”和世界上最大的陨石——渥拉斯顿环形山陨石等非常珍贵的藏品。播客“博物志”也对这场灾难做了一期节目:#121. 「这块陨石以后就是巴西国宝了。」 — 博物志。我就是从这期节目知道这场灾难的。
但是显然去年的这场灾难和今天发生的巴黎圣母院相比,热度完全不可同日而语。今天我在做纸笔书写的 FWP 思维练习时(参见此文)自然地写到了相关的思考。我是通过微信朋友圈、Twitter、微博等社交平台知道这件事情的,这是社交平台最大的特点:对于有一定规模的大事件,传播速度极快。但是除此之外呢?Social Media 几乎给不出多少有效的信息。像是火势多大,造成的损失几何,火灾的原因等等。这些专业的分析是需要专家来做,由权威来发布的。而社交平台上极少有个体有能力可以到这点。这是理所当然的事情,毕竟绝大多数人都只是普通人,这也是我们分工合作的社会的基础。
充斥在社交平台上的“我和圣母院”的自拍让我颇感兴趣,他们发自拍的意义是什么?我觉得可以从几个角度来看:
经济学的基础是“稀缺”,上述三点事实上归根结底都是在表达一个“稀缺性”,是一种对自己拥有“稀缺资源的炫耀”。怎么说?
虽然今天中国出境游的人数在逐年递增。在 2018 年中国出境游达到 1.4 亿人次,其中去法国的大约是 230 万人次。(注意:单位是“人次”而不是“人数”,数据参考文章底部链接。)假设这个单位是人数吧,也就是 230 万人没有重复的,那就是大约一千个人里面有一个人去了法国(而且不一定去了巴黎)。这里只讨论 2018 年一年的,假设十年内去法国的人完全不重复,并且过去十年每年都是 230 万人去(实际上越早时间人次越低),那么撑死也就是每一百个人里有一个人在过去十年去过法国。
这样的人数放在中国的总人口来说依然是少数。所以“去巴黎旅行”这件事情在中国依然是一种“稀缺性资源”,而当时留下的照片就是自己“拥有稀缺资源”的证据。今天正好有这么一件事情,确实是一个好时机对自己的社交圈进行一次广播,提升一下社交印象分。
有爱人设的塑造
如果只是普通的旅行照片,那炫耀出来的仅仅是“此时此刻我在哪里”的稀缺性,但在今天发出来却可以配上三两句话表达一下“惋惜”之情。而这种“惋惜”是建立在“我知道巴黎圣母院有多厉害”的基础之上的。
这就比“我去过巴黎圣母院”要更进一步了。毕竟了解一座古老建筑物的历史,馆藏,在人类社会的地位等等这些额外信息,都是需要付出成本的。虽然普通人没法像高晓松一样对着摄像头一侃就是俩小时,但是发两句简单的漂亮话还是可以做到的。这其实是一种成本极低的人设塑造,而人设塑造为什么重要呢?就在于“有爱”、“有文化”也是一种“稀缺资源”。
事实上发一条朋友圈或者微博,对远在地球另一端的一场灾难表达一下爱心,可能就是一个人能做到的最大的“有爱”了。法国总统马克龙现在表示要对全世界筹款重建圣母院,在朋友圈里表示痛心疾首的人群中,又会有多少人会主动去捐款呢?
这正好是我最近所读的《薛兆丰的经济学讲义》一书中讨论过的一个例子,即人的爱心是非常有限的,这是一个客观事实。而人类社会又是紧密联系互相依赖的,那么这时候如果让人类社会跨域有限的爱心来互相帮助呢?答案是“市场”。所以很难说马克龙的纯粹“慈善募捐”能取得多大成效。假如这场“筹款”并不是一种募捐而是一种市场行为,做个不恰当的比喻:募捐最多的企业可以获得圣母院塔尖大 LOGO 的品牌露出之类的,那么也许会有更好的募捐效果也不一定。
蹭热点蹭热度
对于微信公众号、微博大 V 之类的靠流量卖钱的人或者机构来说,流量即收入。现在有个天降热点,那当然不可暴殄天物,不仅要蹭,而且要蹭得精彩,蹭得高明,毕竟互联网时代群众的注意力极易被转移,热点就是一种时间上的稀缺性资源。况且像“圣母院大火”这样的热点,就是个非常容易往正面形象走的一个命题作文,只要中规中矩,就不会制造出负面文章。而自媒体如果想从这么多批量制造的文章中脱颖而出,可能就得写一些与众不同的观点出来。制造与众不同的过程,也是在制造自己文章的稀缺性。
以前我们在聊郑也夫的《后物欲时代的来临》一书时曾经说过消费主义社会通过堆砌物质,或曰资源来彰显自己的能力。其实拥有大量优质的物质也是一种“稀缺性”,毕竟优质即稀缺,能够拥有大量优质物品的人当然是稀缺的。
所以上面写了这么多关于发自拍,蹭热度之类的观点,好像我是在说这些东西不好,这些东西没有价值。其实不是。如果大家发朋友圈发微博没有任何好处,无论这个好处是炫耀也好,自我满足也好,人设塑造也好,只要没有适当的激励那就不会有人去发,我也就看不到这条消息,也就不知道原来发生了这么大一件事情。所以从信息触达的角度来看,当然是一件好事,但是对于信息传播过中信噪比来说就不一定了,毕竟从这么多人的自拍中想要找到有效信息的成本还是挺高的。所以从信息接收者来说,大量的自拍显然不是一种“优质信息”。
对于大多数发图的人来说,有机会发一发陈年自拍,接受一些赞和围观,满足了这些心理需求就已经挺好了。但是如果发表的人能够发出“对接收者来说更有价值”的信息,是否会更好呢?根据我对薛兆丰的经济学科普的理解,我觉得绝大部分发表者是不会发出比现在这些“更有价值”的东西的,那些更有价值的东西会由专家来发布。这是一个客观的常态,因为“有价值的东西”本身就是一种“稀缺的东西”。
对我来说,我通过朋友圈知道这件事情,那就已经足够了,剩下的信息我会通过其他渠道来获取,比如专业的新闻稿,比如博物志播客的评论等等。
前几天写完上述文字之后我没有立刻发布,因为我并不知道是否要提及“做到更好”。首先我得定义什么是“更好”,而一旦我去定义这个词,我就发现这实际上就是一种“稀缺”资源。而“稀缺”本身就是一种大众所不具备的东西,所以似乎并没有必要去“做到更好”。
但其实这个逻辑是错误的,是因果倒置的。我们不应该因为“稀缺”而不去做,反而是很难做到所以才“稀缺”。
未来的社会应当是会随着人们的认知水平,教育水平的普遍提升而逐步改善的。“发表无用信息”来满足自己的炫耀心理需求的行为会随着时间的推移而逐渐减少,就好像蛇精的照片会随着大众越来越不买账而逐步减少一样。不过根据我的观察,现在是美颜水平变高了,让修图后的人更加接近自然人,但是加上极重美颜的照片已经充斥各大社交平台,说明重美颜依然有其市场,目前无法识别重美颜或者不关心是否美颜的人还是居多。
今天我看到网络上有关于“如何看待朋友圈发圣母院合照的行为”之类的讨论,有人在批判发照片的行为,也有人在为发照片的行为正名。双方的措辞都稍显激烈。
我想起《薛兆丰的经济学讲义》的前言,作者说:
它助你变得更理性、悦纳和进取。你将学会把愿望和结果分开来评判(理性);你将学会先去探究现象背后的原因,而不是动不动就抱怨和指责(悦纳);你将忘记经济学家们津津乐道的均衡世界,而着迷于由创新精神牵引的非均衡的开放社会(进取)。
我在看到朋友圈里充斥这些照片的时候是有感到不舒服的,但由于最近一直在读这本经济学的科普,我很自然地就想要去分析这种“不舒服”背后的原因。
做事从容应对是一个表象而不是一个人的品质或特性,而是由于此人有足够的能力解决他需要面对的问题。同样的,能够宽容待人也不是一个人的品质或特性,而是因为此人足够理解他所面对的不符合预期的事情背后的原因。
读《薛兆丰的经济学讲义》并不能让我成为一名经济学专家,但是我非常喜欢作者笔下的经济学那种反直觉的思维方式。能够采用不同的角度来看待同一件事情,我们就可以用不同的结论来反思同一件事情,也就能够理解不同的人,能够审视不同的结论。
我们在生活中会遇到很多不如意的事情,不符合预期的事情。遇到这些事情会让我们产生负面情绪。如果仅仅停留在负面情绪里我们是得不到进步和解脱的,追求表面的“宽容”也是没有意义的。惟有采用多元的思考,理解事物背后的原因,才有可能辨别真伪,寻找问题的解决之道。这件事情说起来容易做起来难,但是一个人如果不做难的事情,那他就会一直在原地踏步,我想喜欢原地踏步的人应该不会很多。
2019.04.16 - 04.18
于自居
