
在上一篇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 了,可以留意一下黑五之类的营销活动,一次买几年。)