日志系统
NSLog
这是我们最熟悉的日志接口,它位于Foundation框架,它本质是对ASL或ULS的封装,它的原型如下:
// 常用版本
FOUNDATION_EXPORT void NSLog(NSString *format, ...) NS_FORMAT_FUNCTION(1,2);
// va_list版本
FOUNDATION_EXPORT void NSLogv(NSString *format, va_list args) NS_FORMAT_FUNCTION(1,0);
它的格式字符串和Objc的其他函数一样,和C也类似,只是多了%@去输出ObjC对象(调用description),在输出时它会添加日期时间(毫秒精度)、进程名、PID、线程 ID,之后才是消息内容,这些都是在它内部组装的,到底层时是一个完整的字符串,且它是同步操作,因此性能损失明显,除了简单调试不建议使用它。
ASL
Apple System Log其实是一个已经退休的系统,它是Apple 05年引入的一个syslog实现,关于syslog可以看俺之前的文章,apple实现了一个结构化、易查询且高性能的增强版,比如ASL存储的是二进制格式,当然了它提供工具能导出文本视图便于分析。
它同样是一个CS架构,通过unix domain socket通信,由syslogd守护进程接收日志,写入/var/log/asl,并支持广播给订阅者,客户端(如syslog(3)就是用的asl)使用如下接口:
#include <asl.h>
/* 最简单的用法,使用默认客户端 */
asl_log(NULL, NULL, ASL_LEVEL_ERR, "Something went wrong: %s", strerror(errno));
asl_log(NULL, NULL, ASL_LEVEL_INFO, "User logged in: %s", username);
/* 打开一个具名客户端 */
aslclient client = asl_open(
"com.beta.mao", // ident: 标识符,出现在Sender字段
"com.beta", // facility: 设施名
ASL_OPT_STDERR // 选项: 同时输出到stderr
);
// 选项标志
// ASL_OPT_STDERR — 同时写到stderr(开发时有用)
// ASL_OPT_NO_DELAY — 立即连接syslogd,不延迟
// ASL_OPT_NO_REMOTE — 忽略远程过滤器配置
// 设置这个客户端的过滤级别
// 只有<= filter的消息才会被发送
asl_set_filter(client, ASL_FILTER_MASK_UPTO(ASL_LEVEL_DEBUG));
// ↑ 展开为 0xFF,允许所有级别通过
// 使用客户端写日志
asl_log(client, NULL, ASL_LEVEL_DEBUG, "Debug message: value=%d", value);
// 它也可以通过ASL_KEY_READ_UID和ASL_KEY_READ_GID来指定某条消息只能被有权限的用户读取
aslmsg msg = asl_new(ASL_TYPE_MSG);
asl_set(msg, ASL_KEY_MSG, "Sensitive user data");
// 只有 root(UID 0)可以读
asl_set(msg, ASL_KEY_READ_UID, "0");
// -1 表示所有人可读(默认)
// asl_set(msg, ASL_KEY_READ_UID, "-1");
asl_send(client, msg);
asl_free(msg);
// 用完关闭
asl_close(client);
ASL定义了8个日志级别,第二个例子就用ASL_FILTER_MASK_UPTO(level)宏调整了客户端会发送的级别:
#define ASL_LEVEL_EMERG 0 // 系统不可用,极少使用
#define ASL_LEVEL_ALERT 1 // 需要立即处理
#define ASL_LEVEL_CRIT 2 // 严重错误
#define ASL_LEVEL_ERR 3 // 普通错误
#define ASL_LEVEL_WARNING 4 // 警告
#define ASL_LEVEL_NOTICE 5 // 正常但值得注意(默认级别)
#define ASL_LEVEL_INFO 6 // 信息性消息
#define ASL_LEVEL_DEBUG 7 // 调试信息
#define ASL_FILTER_MASK(level) (1 << (level))
#define ASL_FILTER_MASK_UPTO(level) ((1 << ((level) + 1)) - 1)
同时syslogd也存在记录级别,可通过syslog命令临时修改,或是直接修改/etc/asl.conf或/etc/asl/下的每模块配置文件,由于它已经过时了(现在只剩兼容接口,其内部调用的是下一节的ULS),所以不再详细讲它的配置与查询语法,这里就提一下它的常见消费端,asl_search API已经过时,Console.app(控制台)是图形化的很好用,爱用,常用,下面还会用;还有就是syslog了:
# 监视模式,类似于`tail -f`查看实时日志流,添加count则显示最近count条新日志
syslog -w [count]
# 指定输出格式,如std(默认格式) raw(原始格式 输出所有信息) bsd(和system.log里的一样) xml (plist格式),也可以自定义格式,能用哪些占位符自己查
syslog -F '%Datetime% %Sender%[%PID%]: %Message%'
# 过滤: 它支持非常强大的过滤语法,可使用多种及多个表达式,这里简单列几个
# 按进程名过滤
syslog -k Sender MyApp
# 按级别过滤(显示 ERR 及以上)
syslog -k Level Leq 3
# 组合条件(AND 关系)
syslog -k Sender MyApp -k Level Leq 4
# 查看指定 ASL 文件
syslog -f /var/log/asl/2026.03.27.G80.asl
# 输出为文本格式(默认是紧凑格式)
syslog -k Sender MyApp -T std
# 查看所有字段(包括自定义字段)
syslog -k Sender MyApp -F raw
还有很多很多...感兴趣可以直接看帮助~
ULS
现在*OS都在使用统一日志系统(Unified Logging System),有点肯定是多多,比如用户态内核态都能用、用最少的损耗存储最多的信息、隐私数据隔离等等,这里直接看它的架构,它分三部分:
1.客户端: 日志的生产者,即各种使用os_log接口的程序
2.服务端: 日志中转,这里有两个服务: logd负责处理大量的日志输出,如果日志被标记为持久化则将其存储在/var/db/diagnostics下,diagnosticd为日志查看器提供实时流支持
3.日志查看器: 日志的消费者,用来看日志的,在Apple下自带的是控制台和log命令,这个下面介绍
客户端和服务端会通过XPC和共享内存(Apple取名叫Firehose机制)通信,而内核额外会通过/dev/oslog和/dev/oslog_stream这两个特殊的字符设备与用户态通信,大体如下:

关于怎么去写日志可以查文档,这里只提一下它的一个特性,就是除了标准的printf格式化字符外,它还支持%{}扩展,比如%{private}s在非调试模式且没设置专门的Profile下会显示为<private>,而%{errno}d/%{time_t}d/%@/可用来显示错误、时间和对象,而%{uuid_t}.16P/%{xnu.opaque}p/%.*P/%{xnu.innards}...可用来输出特定格式、混淆内容甚至输出特定接口等
我们主要关注的是怎么查看日志,控制台是最常用的,它可以通过""操作"菜单显示信息和调试级别的日志,通过搜索词元精确查找并保存过滤条件,还可以选定设备去看实时日志,包括显示连接的iPhone等设备的日志。不过功能最强大的还得是log命令,它支持多种子命令:
➜ ~ log --help
usage:
log <command>
global options:
-v, --verbose # 详细模式: 输出更多调试信息,方便排查命令本身的问题
commands:
collect # 将系统日志导出并打包成一个.logarchive文件,可以从有故障的机器上采集,然后发到Mac上用控制台查看
config # 查看或更改日志系统的全局设置,可以用它开启某个进程的Debug级别持久化存储,否则有些日志重启就没了
diagnose # 对导出的logarchive文件进行健康检查或自动分析,用于检测日志包是否完整,或者是否存在严重的系统性错误
erase # 删除系统现有的日志数据,当想清理磁盘空间,或者想在一个干净的环境下开始捕捉特定Bug的日志时使用
repack # 使用谓词(Predicate)对现有的logarchive文件进行过滤并生成精简版,如果原始日志包太大,可以用它剔除无关信息,只保留关心的部分
show # 查看并搜索系统已存储的历史日志,这是最常用的命令之一,支持时间段查询、按进程名过滤以及多种输出格式(如 JSON)
stream # 实时观察系统正在生成的日志(类似 tail -f),开发调试时的首选,能在触发某个操作时瞬间看到系统的反馈
stats # 显示日志系统的运行统计数据,可以查看日志占用了多少磁盘空间、各进程的日志生成速率等,用于性能调优
这里面config/erase/collect需要sudo权限,每个子命令可以用log help <command>查看特定的参数,这里就简单列出几条常用的参数:
log stream --level debug --process "BetaMa0" --predicate 'subsystem == "com.beta.app"' # 实时查看指定的APP指定的子系统的日志
log show --start "2024-11-20 14:00:00" --end "2024-11-20 14:15:00" --predicate 'subsystem == "com.apple.network" AND eventMessage CONTAINS[c] "error"' --style syslog # 获取指定时间段,com.apple.network子系统的历史日志,并格式化输出为syslog格式
sudo log collect --last 1h --output my_debug_logs.logarchive # 打包一段日志,让别人分析
官方Trace/Debug工具
lldb
这玩意儿怎么用没啥说的,简单聊一下原理和权限吧,后面的都需要~
调试机制
在Linux下我们的调试使用ptrace,但是在*OS下ptrace被严重阉割了,基本没啥用,所有的动态分析工具都不再使用它,而是直接使用Mach原语,没座,就是下面这些:
/* 附加调试 */
task_for_pid(mach_task_self(), pid, &task_port); // 获取目标程序的任务端口
// 若是要重头开始调,是在posix_spawn()启动时添加POSIX_SPAWN_START_SUSPENDED标志让子进程挂起
/* 内存操作 */
mach_vm_read_overwrite(task, remote_addr, size, local_buf, &bytes_read); // 读目标进程内存
mach_vm_write(task, remote_addr, local_buf, size); // 写目标进程内存(设断点、patch 代码都靠这个)
mach_vm_protect(task, addr, size, FALSE, VM_PROT_READ | VM_PROT_WRITE); // 修改内存保护属性(写代码段前需要先改权限)
/* 线程枚举与寄存器 */
task_threads(task, &thread_list, &thread_count); // 枚举所有线程
thread_get_state(thread, ARM_THREAD_STATE64, &state, &count); // 读寄存器(arm64 通用寄存器)
thread_set_state(thread, ARM_THREAD_STATE64, &state, count); // 写寄存器(修改 PC 实现跳转等)
/* 断点/异常捕获 */
task_set_exception_ports(
task,
EXC_MASK_ALL, // 捕获所有异常类型
exception_port, // lldb 自己的 mach port
EXCEPTION_DEFAULT,
THREAD_STATE_NONE
); // 注册一个exception port,在触发断点或异常时会交由调试器处理
多提一嘴,虽然主要用Mach原语去操作,但是还是会调用ptrace仅剩的功能告知BSD层其状态~
权限
*OS下存在多种加固,比如CS_RESTRICT会限制dyld,而CS_RUNTIME(Hardened Runtime)会限制调试和代码注入,即即使满足一些基本的检查,也还需要额外的特权:
int
cantrace(proc_t cur_procp, kauth_cred_t creds, proc_t traced_procp, int *errp)
{
// 不能trace自己,不能trace已被traced的进程,非root调试者ruid必须等于目标的ruid,且目标不能是setuid binary
if ((kauth_cred_getruid(creds) != kauth_cred_getruid(traced_cred) ||
ISSET(traced_procp->p_flag, P_SUGID)) &&
(my_err = suser(creds, ...)) != 0) {
...
return 0;
}
// 目标调用了PT_DENY_ATTACH(P_LNOATTACH)则直接拒绝
if (ISSET(traced_procp->p_lflag, P_LNOATTACH)) {
*errp = EBUSY;
return 0;
}
// 特权检查 MACF hook → AMFI policy
my_err = mac_proc_check_debug(&cur_ident, cur_cred, &traced_ident);
// ...
}
现在说一下特权:
1.get-task-allow: 这是对被调试程序,有这个权限就能被其他的任意程序获取任务端口
2.task_for_pid-allow: 这是对调试器的,有这个权限能获取其他任务非系统进程(platform binary)的任务端口,不管目标是否有get-task-allow
3.com.apple.system-task-ports*: 这是一系列特权,有它就能调试所有进程,包括系统进程:
task port 强度(从弱到强):
name port < inspect port < read port < control port
entitlement 覆盖范围(从窄到宽):
com.apple.system-task-ports.name.safe → 仅name port,任意目标
com.apple.system-task-ports.read → read port,任意目标(含系统进程)
task_for_pid-allow → control port,仅普通用户进程
com.apple.system-task-ports → control port,任意目标(含系统进程)
com.apple.system-task-ports.control → 以上全部(新系统上的统一替代)
4.com.apple.security.cs.debugger:在拿到任务控制端口后,对普通进程已经可以调试了,但是若目标进程启用了Hardened Runtime,则还需要该权限才能调试,如做内存空间debug映射,修改内存等
注:在iOS上,非开发模式还有额外的限制,如硬件断点/观察点和任务控制端口不可用,异常等也是只读的,完全无法调试,所以需要开启开发者模式。
Dtrace
DTrace(Dynamic Tracing)我之前在Linux里提过,它是Sun公司开发并开源的,后来被多个系统移植,Apple也早早引入了DTrace并针对macOS做了适配,包括:
1.完整的D语言脚本支持
2.提供dtrace命令行工具
3.支持大多数标准的provider,如syscall、proc、io、vm、sched、profile、fbt、pid等,还增加了objc来追踪ObjC方法调用
DTrace和eBPF在很多设计上很像(应该锁后者借鉴了很多思路😛),它也是用户态编写并编译d脚本,交给内核执行,内核使用DIF虚拟机执行编译后的字节码:
用户态
┌─────────────────────────────────────────────────────┐
│ libdtrace(D 编译器 + DOF 生成 + 运行时控制) │
│ ↓ DOF(二进制程序) │
└─────────────────────────────────────────────────────┘
↕ ioctl
════════════════════════════════════════════════════════
内核态
┌─────────────────────────────────────────────────────┐
│ DTrace Framework(核心框架) │
│ Probe 管理 │ ECB 管理 │ DIF 虚拟机 │ 缓冲区管理 │
├─────────────────────────────────────────────────────┤
│ Providers(各类探针提供者) │
│ fbt │ sdt │ systrace │ profile │ fasttrap │ ... │
└─────────────────────────────────────────────────────┘
需要注意,macOS要关闭SIP才能使用它的完整功能,而iOS就不必做梦了,内核压根没编译该能力,所以它只能在macOS上用。
原理
DTrace是通过动态修改内核函数的机器码来实现的,以FBT为例,在arm64下它识别如下指令:
// 帧推入指令(函数 prologue): stp fp, lr, [sp, #val]
#define FBT_IS_ARM64_FRAME_PUSH(x) \
(((x) & 0xffc07fff) == 0xa9007bfd || ((x) & 0xffc07fff) == 0xa9807bfd)
// 帧弹出指令(函数 epilogue): ldp fp, lr, [sp, #val]
#define FBT_IS_ARM64_FRAME_POP(x) \
(((x) & 0xffc07fff) == 0xa9407bfd || ((x) & 0xffc07fff) == 0xa8c07bfd)
// 返回指令
#define FBT_IS_ARM64_RET(x) \
(((x) == 0xd65f03c0) || ((x) == 0xd65f0fff))
// 补丁值,它是一条非法指令,触发undefined instruction异常,在fbt_enable()时会被写入
#define FBT_PATCHVAL 0xe7eeee7e
当调用函数时,会触发如下链路:
fbt_init // 初始化
↓
dtrace_register("fbt", pops) // 注册 Provider
↓
fbt_provide_module() // 它会扫描内核符号表,给每个函数创建entry和return的probe
↓
ml_nofault_copy(FBT_PATCHVAL → 函数入口) // 写入非法指令
↓ (执行到此处)
UND 异常 → fbt_perfCallback()
↓
dtrace_invop(pc, regs) → fbt_invop()
↓
dtrace_probe(fbt->fbtp_id, arg0, arg1, ...) // 触发探针
↓
模拟执行原始指令,恢复正常执行流 // 它会使用fbt_probe去记录补丁地址,返回值,触发指令,原始指令等信息,所以能恢复正常流程
不仅ftb,所有的provider都会调用到dtrace_probe它,它的流程为:
dtrace_probe(id, ...)
↓
probe = dtrace_probes[id-1] // O(1) 查找
↓
for (ecb = probe->dtpr_ecb; ecb; ecb = ecb->dte_next)
├─ dte_cond 安全条件检查
├─ dtrace_buffer_reserve() // 预留缓冲区空间
├─ 写入记录头(EPID + 纳秒时间戳)
├─ dtrace_dif_emulate(predicate) // 谓词求值
│ └─ 为 false 则 continue
└─ 执行 action 链表
├─ DIF 表达式求值
├─ dtrace_aggregate() // 聚合操作
└─ 写入数据
通过这就走完了一个完整的探测流程,下面介绍它的使用方式~
Probe
从上图可以看到Dtrace里有很多概念,但最核心是Probe(探针),每个Probe由provider:module:function:name四元组组成,provider表示probe的来源,如syscall为系统调用,profile为基于时间的观测器;module是probe所在的代码模块,因probe而不同,如对内核函数边界(fbt)来说可能是mach_kernel或是某个驱动,对syscall来说通常为空因为系统调用不属于某个特定模块;function是probe所在的具体函数名;name是探针名,是probe在函数中的具体位置或事件类型,常见值有:
| name | 含义 |
|---|---|
entry / return |
刚进入函数时/函数返回时触发 |
tick-1sec |
profile provider 专用,每秒触发一次 |
997hz |
profile provider 专用,每秒触发 997 次 |
exec-success |
proc provider 专用,exec 成功后触发 |
create / exit |
proc provider 专用,进程创建/退出 |
start / done |
io provider 专用,I/O 开始/完成 |
这些字段可以用*通配符或留空,留空表示匹配所有,例如:
syscall:::entry /* module 和 function 留空,匹配所有系统调用入口 */
syscall::*read*:entry /* function 名含 read 的系统调用入口 */
除了上面提到的,它还有三个特殊Probe:
BEGIN { /* DTrace启动时执行一次 */ }
END { /* DTrace退出时执行一次,常用于输出聚合 */ }
ERROR { /* probe执行出错时触发 */ }
D语法
dtrace使用d语言编写脚本,其文件名通常以.d结尾,文件基本结构为:
/* 注释 */
#pragma D option quiet /* 编译器选项 */
/* 全局变量声明(可选) */
int count;
/* probe描述 / 谓词 / 动作 */
provider:module:function:name
/predicate/ /* 可选的谓词,决定是否执行动作 */
{
action; /* 动作语句 */
}
/* 这里的谓词+动作为ECB/ECF */
首先是编译器选项,它支持设置如下选项:
#pragma D option quiet /* 不打印"dtrace: N probes enabled" */
#pragma D option bufsize=64m /* 输出缓冲区大小 */
#pragma D option dynvarsize=32m /* 动态变量(关联数组等)内存上限 */
#pragma D option stackframes=100 /* ustack/stack 最大帧数 */
#pragma D option flowindent /* 函数调用自动缩进 */
#pragma D option defaultargs /* 未提供的$N参数默认为 0 */
#pragma D option destructive /* 允许stop()/raise()/system()等破坏性操作 */
#pragma D option aggsize=64m /* 聚合变量内存上限 */
#pragma D option aggrate=1sec /* 聚合数据刷新频率 */
#pragma D option switchrate=10hz /* 缓冲区切换频率 */
然后是变亮,它可以定义多种数据类型:
/* 标量变量(全局) */
int x; /* 全局整数,所有 CPU 共享 */
string s;
uint64_t count;
/* 线程局部变量 */
self->start = timestamp; /* 每个线程独立的变量 */
/* 关联数组 */
counts[execname]++; /* 以进程名为 key */
latency[pid, tid] = timestamp; /* 多维 key */
/* 子句局部变量 */
this->x = arg0; /* 仅在当前 probe 触发的单次执行中有效 */
/* 聚合变量 */
@counts[execname] = count();
@bytes[execname] = sum(arg2);
@dist[execname] = quantize(arg2);
并且它内置了一些变量可以直接使用:
| 变量 | 类型 | 含义 |
|---|---|---|
pid |
int | 当前进程PID |
tid |
int | 当前线程ID |
execname |
string | 进程名 |
uid / gid |
int | 用户/组ID |
timestamp |
int | 纳秒时间戳(系统启动以来) |
walltimestamp |
int | 纳秒墙钟时间 |
cpu |
int | 当前CPU编号 |
arg0~arg9 |
uint64 | probe参数(整数) |
args[] |
类型化数组 | 类型化的probe参数 |
probeprov |
string | 当前probe的provider名 |
probemod |
string | 当前probe的module名 |
probefunc |
string | 当前probe的function名 |
probename |
string | 当前probe的name |
curthread |
thread_t* | 当前线程结构体指针 |
curlwpsinfo |
lwpsinfo_t* | 当前线程信息 |
curpsinfo |
psinfo_t* | 当前进程信息 |
errno |
int | 最近系统调用的errno(return probe) |
stackdepth |
int | 当前调用栈深度 |
它还提供了多种函数,如聚合函数:
| 函数 | 说明 |
|---|---|
count() |
计数 |
sum(x) |
求和 |
avg(x) |
平均值 |
min(x) |
最小值 |
max(x) |
最大值 |
stddev(x) |
标准差 |
quantize(x) |
2 的幂次直方图 |
lquantize(x, lo, hi, step) |
线性直方图 |
llquantize(x, factor, lo, hi, steps) |
对数线性直方图 |
Action函数:
/* 输出 */
trace(expr) /* 追踪一个值 */
tracemem(addr, size) /* 追踪内存块 */
printf(fmt, ...) /* 格式化输出 */
printa(fmt, @agg) /* 格式化输出聚合 */
print(expr) /* 打印类型化值(DTrace 1.9+)*/
/* 栈追踪 */
stack() /* 内核调用栈 */
stack(depth) /* 指定深度的内核调用栈 */
ustack() /* 用户态调用栈 */
ustack(depth) /* 指定深度的用户态调用栈 */
jstack() /* Java 栈(需要 JVM helper)*/
/* 字符串处理 */
copyinstr(addr) /* 从用户态地址读取字符串 */
copyinstr(addr, maxlen) /* 限制长度 */
copyin(addr, size) /* 从用户态读取内存块 */
stringof(addr) /* 将内核地址转为字符串 */
substr(str, start) /* 子字符串 */
substr(str, start, len)
strjoin(s1, s2) /* 字符串拼接 */
strlen(str) /* 字符串长度 */
strtoll(str, base) /* 字符串转整数 */
/* 进程控制 */
stop() /* 暂停当前进程(发送 SIGSTOP)*/
raise(sig) /* 向当前进程发送信号 */
system(cmd) /* 执行 shell 命令(慎用)*/
exit(status) /* 退出 DTrace */
/* 时间获取 */
timestamp /* 纳秒,单调递增 */
vtimestamp /* 虚拟时间(仅计算当前线程在 CPU 上的时间)*/
并不是每次执行匹配的probe都会执行Action,他还受谓词控制(如果存在),谓词是/包围的布尔表达式,为真才执行action,如:
/* 只追踪Safari */
syscall::read:entry
/execname == "Safari"/
{
printf("Safari read %d bytes\n", arg2);
}
/* 多条件 */
syscall:::entry
/pid == $1 && uid != 0/
{
trace(probefunc);
}
最后是控制流,熟悉eBPF就能猜到DTrace是不支持循环的,只支持有限的条件判断,如:
/* 三元运算符 */
this->result = (arg0 > 0) ? "positive" : "non-positive";
syscall::read:return
{
if (arg1 > 1024) {
printf("large read: %d\n", arg1);
} else {
printf("small read: %d\n", arg1);
}
}
编写的d脚本会被编译为DIF字节码,通过dtrace_ioctl交给内核,再由沙盒化的RISC虚拟机执行(dtrace_dif_emulate),完整流程为:
D 脚本 ──> 编译──> DOF ──ioctl(DOF)──> 内核解析/创建 ECB
↓
ioctl(GO) 启用探针
↓
ioctl(BUFSNAP) <──复制──── 切换缓冲区 <── probe 触发/写数据
代码样例
在/usr/bin之下有很多自带的dtrace,这里对每种provider举例说明:
syscall Provider
追踪系统调用,是最常用的 provider:
/* 统计各进程系统调用次数 */
syscall:::entry
{
@calls[execname] = count();
}
/* 追踪open系统调用的文件路径 */
syscall::open:entry,
syscall::open_nocancel:entry
{
printf("%s opened: %s\n", execname, copyinstr(arg0));
}
/* 统计read的字节数分布 */
syscall::read:return
/arg1 > 0/
{
@bytes[execname] = quantize(arg1);
}
proc Provider
追踪进程/线程生命周期:
proc:::exec-success { printf("exec: %s\n", curpsinfo->pr_psargs); }
proc:::exit { printf("exit: %s (pid=%d)\n", execname, pid); }
proc:::fork { printf("fork: parent=%d child=%d\n", pid, arg0); }
proc:::signal-send { printf("%s sent signal %d to pid %d\n", execname, arg1, args[1]->pr_pid); }
profile Provider
基于时间的采样,用于CPU profiling:
/* 每秒997次采样(质数避免与系统频率共振)*/
profile-997hz
/pid == $target/
{
@stacks[ustack()] = count();
}
/* 固定时间间隔 */
profile:::tick-1sec
{
printa(@counts);
clear(@counts);
}
fbt Provider(需关闭 SIP)
内核函数边界追踪(Function Boundary Tracing):
/* 追踪所有TCP相关内核函数 */
fbt::tcp*:entry
{
printf("%s called\n", probefunc);
}
/* 测量内核函数耗时 */
fbt::vfs_read:entry { self->ts = timestamp; }
fbt::vfs_read:return /self->ts/
{
@lat[probefunc] = quantize(timestamp - self->ts);
self->ts = 0;
}
pid Provider
追踪用户态进程的函数调用:
/* 追踪指定进程的所有函数调用 */
pid$target:::entry
{
printf("%s\n", probefunc);
}
/* 追踪特定库的函数 */
pid$target:libSystem*:malloc:entry
{
printf("malloc(%d) called\n", arg0);
ustack();
}
/* 追踪函数返回值 */
pid$target:libSystem*:malloc:return
{
printf("malloc returned %p\n", arg1);
}
objc Provider(macOS 特有)
追踪Objective-C方法调用:
/* 追踪所有NSString方法 */
objc$target:NSString:-*:entry
{
printf("[NSString %s]\n", probefunc);
}
/* 追踪特定方法 */
objc$target:*ViewController:-viewDidLoad:entry
{
printf("viewDidLoad called in %s\n", probemod);
ustack();
}
io Provider
追踪I/O操作:
io:::start
{
printf("%s: %s %d bytes at offset %d\n",
execname,
args[0]->b_flags & B_READ ? "read" : "write",
args[0]->b_bcount,
args[0]->b_blkno);
}
/* 统计各进程 I/O 字节数 */
io:::done
{
@bytes[execname] = sum(args[0]->b_bcount);
}
vm Provider
追踪虚拟内存事件:
vm:::pgin { @pageins[execname] = count(); }
vm:::pgout { @pageouts[execname] = count(); }
vm:::anon-create { printf("anon region created by %s\n", execname); }
sched Provider
追踪调度器行为:
sched:::on-cpu { self->oncpu = vtimestamp; }
sched:::off-cpu /self->oncpu/
{
@runtime[execname] = sum(vtimestamp - self->oncpu);
self->oncpu = 0;
}
使用
dtrace
这是最直接的命令了,如:
# 列出所有可用 probe(数量巨大,建议加过滤)
sudo dtrace -l | head -50
sudo dtrace -l -n 'syscall:::entry' | wc -l
# 单行命令(-n)
sudo dtrace -n 'syscall:::entry { @[execname] = count(); }'
# 运行脚本文件
sudo dtrace -s script.d
# 指定目标进程($target 变量)
sudo dtrace -s script.d -p 1234
# 启动并追踪命令($target 自动绑定)
sudo dtrace -s script.d -c "ls -la"
# 常用选项
sudo dtrace -n '...' \
-o output.txt \ # 输出到文件
-q \ # quiet 模式(不打印 probe 触发计数)
-x bufsize=64m \ # 设置缓冲区大小
-x stackframes=50 \ # 栈帧深度
-x dynvarsize=64m \ # 动态变量空间
-x flowindent # 函数调用缩进显示
dtruss等
/usr/bin下有很多自带的基于dtrace的工具,比如strace替代品dtruss:
sudo dtruss -p <PID> # 追踪进程系统调用
sudo dtruss -f ls /tmp # 追踪命令及其子进程
sudo dtruss -c ls /tmp # 统计系统调用次数
还有追踪文件打开操作的opensnoop,追踪磁盘I/O的iosnoop,追踪进程创建的execsnoop和追踪新进程的newproc等~
instruments
这是xcode里的性能分析与调试工具套件,它本质是DTrace的图形化前端,它内置了很多模板,是我们可以用很简单的方式去使用DTrace,之后细嗦~

KDebug
它是XNU内核里一个强大的追踪设施,从能力上它比DTrace要弱点,但是它胜在iOS也能用(尽管它的编译选项支持完全禁用,iOS没有这么做)。
原理
先看眼它的架构图:
┌─────────────────────────────────────────────────────────────────────┐
│ User Space │
│ ┌──────────────┐ ┌─────────────────┐ ┌─────────────────────┐ │
│ │ kdebug_trace │ │ sysctl interface │ │ typefilter mmap │ │
│ │ syscall │ │ (KERN_KDEBUG) │ │ (8KB 位图共享内存) │ │
│ └──────┬───────┘ └────────┬────────┘ └──────────┬──────────┘ │
└─────────┼───────────────────┼──────────────────────┼────────────────┘
│ syscall │ sysctl │ mmap
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ Kernel │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ kdebug.c (核心事件记录引擎) │ │
│ │ KDBG宏 → kernel_debug() → _write_trace_record() │ │
│ └──────────────────────────────┬────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────────▼────────────────────────────────┐ │
│ │ 分层存储系统 │ │
│ │ kd_control(全局控制) + kd_buffer(缓冲管理) │ │
│ │ kd_bufinfo[cpu0] kd_bufinfo[cpu1] ... (Per-CPU 链表头) │ │
│ │ ↓ ↓ │ │
│ │ kd_storage → kd_storage → ... (每单元2048条事件) │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ [过滤层] typefilter(8KB位图) / RANGE过滤 / EXACT精确匹配 │
│ [协处理器] kd_coproc → kernel_debug_enter() 注入IOP事件 │
│ [triage] kdebug_triage.c → 线程故障诊断轻量缓冲 │
│ [kperf] kdebug_trigger.c → 事件触发性能采样 │
└─────────────────────────────────────────────────────────────────────┘
不像DTrace动态Patch机器码,kdebug是在编译时就静态编译进了stub,并且用全局变量控制是否生效:
#define __improbable(x) __builtin_expect(!!(x), 0) // 它通过__builtin_expect来优化分支预测,减少分支判断带来的损耗
#if (KDEBUG_LEVEL >= KDEBUG_LEVEL_STANDARD)
#define KERNEL_DEBUG_CONSTANT(x, a, b, c, d, e) \
do { \
if (KDBG_IMPROBABLE(kdebug_enable & ~KDEBUG_ENABLE_PPT)) { \
kernel_debug((x), (uintptr_t)(a), (uintptr_t)(b), (uintptr_t)(c), \
(uintptr_t)(d),(uintptr_t)(e)); \
} \
} while (0)
// 最终会变成 if (kdebug_enable) { kernel_debug(...); }
它的初始化流程如下:
kernel_startup
└── kdebug_startup() (bsd/kern/kdebug_common.c)
├── lck_spin_init() 初始化trace/triage两套存储锁
├── kdebug_init(nkdbufs, ...) (bsd/kern/kdebug.c)
│ ├── typefilter_create() 创建 8KB 类型过滤器位图
│ └── kdebug_trace_start()
│ ├── kdbg_reinit() 分配 Per-CPU 缓冲区
│ ├── KDBG_NOWRAP 标志设置 (非 wrap 模式)
│ ├── kdbg_enable_typefilter()
│ ├── kdbg_set_tracing_enabled(true)
│ └── kernel_debug_early_end() 将早期静态缓冲区事件迁移到真实缓冲区
└── create_buffers_triage() 创建并立即启用 triage 缓冲区
Debug ID
它在报告时只记录一个32位的debugid和前四个64位参数,并且自动添加时间和线程ID、CPU ID等信息,其中debugid由四部分组成,构成如下:
╭──────────┬──────────┬───────────────┬────╮
│ class │ subclass │ code │ fn │
│ 8 bits │ 8 bits │ 14 bits │ 2b │
╰──────────┴──────────┴───────────────┴────╯
31 24 23 16 15 2 1 0
这里的class是功能大类,如DBG_MACH、DBG_BSD、DBG_FSYSTEM等,subclass是子类,如DBG_MACH_SCHED、DBG_MACH_VM等,code是子类内的具体事件编号,而fn是事件状态,如DBG_FUNC_NONE=0(瞬时)、DBG_FUNC_START=1(区间开始)、DBG_FUNC_END=2(区间结束)等。debug id唯一标识一个事件的类型,在使用时有三层概念:
event ID = class + subclass + code // 不含fn,描述"是什么事件",同一个event ID的START和END事件可以组成一个时间区间
debug ID = event ID + func // 完整的32位,是实际写入`kd_buf.debugid`的值,包含完整语义
CSC = class + subclass // 高16位,用于typefilter过滤位图的索引,即通过CSC决定是否要记录日志
使用
用户态通过sysctl操作kdebug:
int mib[3] = { CTL_KERN, KERN_KDEBUG, 操作码 };
sysctl(mib, 3, oldp, &oldlen, newp, newlen);
// 对于需要附加参数的操作(如 KERN_KDENABLE):
int mib[4] = { CTL_KERN, KERN_KDEBUG, 操作码, 参数值 };
sysctl(mib, 4, oldp, &oldlen, newp, newlen);
它支持的所有操作如下:
#define KERN_KDEFLAGS 1 // 设置flags(或运算,仅限KDBG_USERFLAGS: NOWRAP, CONTINUOUS_TIME, DISABLE_COPROCS, MATCH_DISABLE)
#define KERN_KDDFLAGS 2 // 清除flags(与非运算,仅限 KDBG_USERFLAGS 范围内的位)
#define KERN_KDENABLE 3 // 启用/禁用trace(mib[3]=1启用,0禁用;需先调用KERN_KDSETUP)
#define KERN_KDSETBUF 4 // 设置缓冲区大小(单位: 事件条数 kd_buf;必须在KERN_KDSETUP前调用)
#define KERN_KDGETBUF 5 // 读取缓冲区状态(返回kbufinfo_t: 事件容量, 是否停止, flags, 线程数, owner_pid)
#define KERN_KDSETUP 6 // 分配Per-CPU缓冲区(真正申请内存;必须在KERN_KDENABLE之前调用)
#define KERN_KDREMOVE 7 // 停止trace并释放所有缓冲区(清理现场,重置状态)
#define KERN_KDSETREG 8 // 设置过滤器(支持按Class, Subclass, Range或精确EventID过滤)
#define KERN_KDGETREG 9 // 读取当前过滤器设置(已废弃,返回 EINVAL)
#define KERN_KDREADTR 10 // 批量复制事件到用户态(oldp=kd_buf 数组,返回写入的条数)
#define KERN_KDPIDTR 11 // 设置进程 PID 白名单(KDBG_PIDCHECK 模式,只记录特定进程事件)
#define KERN_KDTHRMAP 12 // 读取线程映射表(历史接口,现已被 stackshot 替代)
#define KERN_KDPIDEX 14 // 设置进程 PID 黑名单(KDBG_PIDEXCLUDE 模式,排除特定进程事件)
#define KERN_KDSETRTCDEC 15 // 已废弃(设置 RTC 解码器)
#define KERN_KDGETENTROPY 16 // 已废弃(获取熵)
#define KERN_KDWRITETR 17 // 将事件以RAW V1格式直接写入文件描述符(fd 由 mib[3] 传入)
#define KERN_KDWRITEMAP 18 // 将线程映射表写入文件描述符(fd 由 mib[3] 传入)
#define KERN_KDTEST 19 // 内核自测(仅 DEVELOPMENT/DEBUG 内核可用)
#define KERN_KDREADCURTHRMAP 21 // 读取当前实时线程映射(不需要缓冲区已初始化)
#define KERN_KDSET_TYPEFILTER 22 // 设置8KB类型过滤位图(按 class/subclass 组合过滤,自动切换模式)
#define KERN_KDBUFWAIT 23 // 阻塞等待缓冲区达到半满阈值或超时(适合高效读取循环)
#define KERN_KDCPUMAP 24 // 读取CPU映射表(RAW V1 格式,关联 cpuid 与硬件名称)
#define KERN_KDCPUMAP_EXT 25 // 读取扩展CPU 映射表(包含协处理器/IOP 等额外信息)
#define KERN_KDSET_EDM 26 // 设置"事件匹配后自动禁用"掩码(用于捕获特定瞬间并停止 trace)
#define KERN_KDGET_EDM 27 // 读取当前的事件匹配禁用 (EDM) 掩码设置
#define KERN_KDWRITETR_V3 28 // 将事件以RAW V3分块格式写入文件(支持更高精度和协处理器数据)
所有软件都会遵守一定的顺序去调用他们,例如:
/* --- Kdebug 生命周期配置与执行 --- */
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDSETBUF, n_events}, 4, NULL, NULL, NULL, 0); // 设置期望的缓冲区大小(事件条数)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDSETUP}, 3, NULL, NULL, NULL, 0); // 分配内核缓冲区(Per-CPU 链表)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDEFLAGS, KDBG_NOWRAP}, 4, NULL, NULL, NULL, 0); // 设置行为标志(如禁止循环覆盖)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDSET_TYPEFILTER}, 3, NULL, NULL, filter, 8192); // 设置 8KB class/subclass 过滤位图
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDSETREG}, 3, NULL, NULL, ®, sizeof(reg)); // 设置范围/精确值过滤器 (kd_regtype)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDPIDTR}, 3, NULL, NULL, ®, sizeof(reg)); // 设置进程白名单 (value1=pid)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDPIDEX}, 3, NULL, NULL, ®, sizeof(reg)); // 设置进程黑名单 (value1=pid)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDENABLE, 1}, 4, NULL, NULL, NULL, 0); // 启动 trace (KDEBUG_ENABLE_TRACE)
/* --- 数据采集与监控 --- */
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDBUFWAIT}, 3, &count, &len, NULL, timeout_ms); // 阻塞等待缓冲区半满或超时
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDGETBUF}, 3, &info, &info_len, NULL, 0); // 查询当前缓冲区状态 (kbufinfo_t)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDREADCURTHRMAP}, 3, buf, &len, NULL, 0); // 读取当前线程映射 (kd_threadmap)
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDREADTR}, 3, events, &len, NULL, 0); // 批量读出事件到 kd_buf 数组
/* --- 停止与清理 --- */
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDENABLE, 0}, 4, NULL, NULL, NULL, 0); // 停止trace
sysctl((int[]){CTL_KERN, KERN_KDEBUG, KERN_KDREMOVE}, 3, NULL, NULL, NULL, 0); // 释放缓冲区,重置全局状态,这一步很重要,如果没做好会导致物理内存泄漏
注: kdebug同时只能被一个进程使用,当它被使用时其他进程会返回失败~
相关工具
这些工具用起来相对简单,这里就直接列一下:
1.kdv(kDebugView): 这是Jonathan Levin开发的工具,可在这里下载,使用方式极其简单~
2.tailspin: 它包含一个守护进程/usr/libexec/tailspind和一个命令行工具/usr/bin/tailspin,可以用tailspin save保存日志去其他地方分析
3.ktrace: 它是 libktrace 的命令行接口,可以读取 .tracev3 文件并与内核设施交互
4.kperf: 是一个内核分析框架,它在Kdebug的基础上增加了采样(Sampling)功能,当触发特定的调试ID时,kperf 会执行采样动作,记录线程信息、内核/用户堆栈、内存信息等
5.kpc: 这是 kperf 的子功能,专门用于访问机器的性能计数器(Performance Counters)
6.Instruments.app: 它也支持kdebug哦~
7.fs_usage: 用于追踪文件系统活动
8.sc_usage: 用于追踪系统调用的使用情况
9.latency: 用于监控系统延迟
三方Instruments工具
Objective-See Utils
怀念Windows的sysinternals,Apple把macOS的权限收的紧紧的,内核扩展已经没戏了,还好剩点良心留了个系统扩展,Objective-See就在Endpoint Security Framework基础上开发了几个好用的工具,包括ProcessMonitor、File Monitor和DNSMonitor,用法很简单就不多说了~
tweak
tweak就是开发一个动态库去hook原始逻辑,从而修改/添加功能,和android类似,ios也有很多框架可以做这事,通常大家只会逮着最好用的使,但一个完整的Tweak开发链路涉及很多部分: 编写Hook(Theos + Logos)→ 打包(.deb)→ 注入加载(ElleKit/libhooker/Substrate)→ 分发(Sileo/Cydia),我先简单列一下防止摸着头脑:
工具类别简介
开发框架
它提供hook代码开发、编译、打包甚至部署一条龙,主要就是提供构建系统(如Makefile模板)和预处理系统(如Logos),常见的有:
1.Theos: 这是主流的开发框架,现在还在活跃维护
2.Dragon: 这是一个较新的框架,旨在提供更轻量、更快速地构建能力
3.iOSOpenDev: 是个早期的基于xcode的开发插件
4.MonkeyDev: 这是国人基于iOSOpenDev开发的插件,也是和xcode融合,提供了大量额外的能力简化开发过程
使用它们能通过少量的代码开发出一个dylib,并被大包为deb供目标系统安装,这也是等下要重点介绍的过程。
Hook引擎
上面编写的库本身是没有hook能力的,相反它们是依赖其他运行时库来提供hook甚至库注入能力,这个运行时库就是这里说的Hook引擎,不过在聊它们前,咱需要先看看ios上的hook底层技术有哪些:
1.GOT/PLT Hook: 这是对导出符号的,包括C/swift的,比如经典的fishhook可做这个
3.Inline Hook: C和Swift都需要,就是动态劫持机器码跳转,比如我们熟悉的Dobby
4.Method swizzling: 这是ObjC专有的,OC的方法调用本质就是消息发送(objc_msgSend),方法实现存在一张可修改的表里,可以在运行时修改,甚至官方就提供了这个能力,如method_exchangeImplementations、method_setImplementation、class_addMethod等,所以这是最简单的
好了,现在可以看看一些流行的引擎了:
1.MobileSubstrate / Cydia Substrate: 这是最经典的Hook框架,提供 MSHookMessageEx(ObjC 方法 hook)和MSHookFunction(C函数hook)两个核心API,是整个越狱生态的基石,自带SubstrateLoader负责库加载
2.libhooker: 它是Procursus/Odyssey越狱生态中的替代品,性能更好,兼容Substrate API同时支持arm64e,内置loader
3.Substitute: 它是Sileo/Chimera越狱使用的开源Hook框架,作为Substrate的开源替代
应用管理
即包管理/应用商店的角色,比如:
1.Cydia: 它是经典的包管理器,其本质是APT/dpkg的图形化前端,不过从18年开始作者基本就不维护它了
2.Sileo: 这是目前主流的包管理器,同样是基于APT/dpkg的,用它!
其他还有像Zebra、installer 5等小众工具可用~
开发简介
这里以theos为例,它支持直接安装在ios上开发,不过我们还是在macOS搞方便,直接运行下面的命令安装:
bash -c "$(curl -fsSL https://raw.githubusercontent.com/theos/theos/master/bin/install-theos)"
它会把工具注册到环境变量,所以可以直接用NIC(New Instance Creator)初始化一个项目,它里面有很多模板,比如:
~$ $THEOS/bin/nic.pl
NIC 2.0 - New Instance Creator
------------------------------
[1.] iphone/application_modern
[2.] iphone/application_swift_modern
[3.] iphone/application_swiftui
[4.] iphone/control_center_module-11up
[5.] iphone/framework
[6.] iphone/library
[7.] iphone/null
[8.] iphone/preference_bundle
[9.] iphone/preference_bundle_swift
[10.] iphone/theme
[11.] iphone/tool
[12.] iphone/tool_swift
[13.] iphone/tweak
[14.] iphone/tweak_swift
[15.] iphone/tweak_with_simple_preferences
[16.] iphone/xpc_service_modern
Choose a Template (required): 13 # 这里是最常用的模板
Project Name (required): Example # 项目名称,一般取和hook目的相关的
Package Name [com.yourcompany.example]: dev.theos.example # 逆DNS的名称
Author/Maintainer Name [Craig Federighi]: Craig Federighi <notfederighi@theos.dev> # 开发者信息,官方说最好写真的,方便其他使用者联系
[iphone/tweak] MobileSubstrate Bundle filter [com.apple.springboard]: # 要hook的目标bundle id
[iphone/tweak] List of applications to terminate upon installation (space-separated, '-' for none) [SpringBoard]:
Instantiating iphone/tweak in example/...
Done.
# 之后就创建了项目目录和文件,如这里为4个文件
~$ cd example
~/example$ ls
Example.plist Makefile Tweak.x control
我们可能需要修改Makefile去设置编译目标、部署地址等,修改control去设置hook相关,如制定hook引擎,而hook逻辑是在Tweak.x里编写,它使用logos语法,详见Logos: Syntax,写好后就可以编译打包甚至直接安装到目标设备,如:
# 修改control
➜ PredicatePatch cat control
Package: me.local.predicatepatch
Name: PredicatePatch
Version: 0.0.1
Architecture: iphoneos-arm64
Description: An awesome MobileSubstrate tweak!
Maintainer: B3taMa0
Author: B3taMa0
Section: Tweaks
# Depends: mobilesubstrate (>= 0.9.5000) # 指定运行时依赖,因为我使用tweakloader,这一行可以直接删除
# 修改makefile
➜ PredicatePatch cat Makefile
THEOS_PACKAGE_SCHEME = rootless # 现在越狱都是rootless的,这行保持不变,它会安装在/var/jb下面
TARGET := iphone:clang:latest:14.0 # 这里的目标也要写新点,旧的不支持arm64
INSTALL_TARGET_PROCESSES = Taobao4iPhone
ARCHS = arm64 arm64e # 构建目标,通常都是arm64e,可以写多个,生成fat bin
include $(THEOS)/makefiles/common.mk
TWEAK_NAME = PredicatePatch
PredicatePatch_FILES = Tweak.x
PredicatePatch_CFLAGS = -fobjc-arc
include $(THEOS_MAKE_PATH)/tweak.mk
# 开始开发
➜ PredicatePatch cat Tweak.x
#import <Foundation/Foundation.h> // 导入头文件
@interface _NSPredicateUtilities : NSObject
+ (void)_predicateSecurityAction;
@end
%hook _NSPredicateUtilities
+ (void)_predicateSecurityAction {
// 这里啥都不做,hook 安全检查为空,绕过检查
}
%end
Frida
关于Frida的原理和使用不再介绍,可看之前的文章,这里就提一下安装方式:
1.越狱设备: Github上没有直接针对ios的frida-server供下载,而是要通过在Sileo/Cydia中添加源(https://build.frida.re)去下载frida
2.非越狱设备: 就是重打包的路子,将FridaGadget.dylib插入应用重签名
参考
[1] *OS Internals: Volume I - Chapter 15 -- Jonathan Levin
[2] 逆向调试利器: Frida -- crifan