我在做一个能在后台给 macOS 微信发消息的工具:不弹窗、不抢鼠标键盘、不切到微信界面,命令行一句话,消息就发出去了。它最让我头疼的不是「能不能发」,是「发得稳不稳」。有时候发出去了,有时候没动静,有时候微信整个卡死转圈,有时候——最糟的一次——微信起来弹一个「数据库已损坏」。
折腾到最后我把发送链彻底改了一遍。现在微信全程不被任何调试器碰。这篇就讲清楚老办法为什么会把数据库搞坏,新办法怎么绕开,中间我还亲手把自己的微信干掉过一次。

老办法:挂个断点,在对的时刻改一个字
微信发消息这条路,关键就一步:消息要发给谁,是从某个会话对象里的一个字段读出来的。你在聊天框里打字、回车,微信内部把这个字段当成「收件人」。
老办法是这么干的:
- LLDB 附着到微信进程,在「读收件人」那条指令上下个断点;
- 用 CGEvent 往微信进程灌入按键,把要发的文字打进当前聊天框;
- 打字会触发那条指令,断点命中,我趁微信停住,把收件人字段改写成我真正想发的人;
- 放行,微信拿着被我改过的字段把消息发出去。
模拟键盘这步本身没问题,CGEvent 直接投递给指定进程,不需要微信在前台。真正的麻烦是第一步那个断点。
断点意味着 LLDB 用 ptrace 把微信「拴」住了。平时没事,微信照常跑。但 ptrace 有个性质:被调试的进程随时可能被停下来,而停的时机不归你管。最危险的组合是:微信正在往本地数据库写一条消息,写到一半,Mac 睡眠了,或者我的守护进程崩了——微信就被丢在「写数据库的半路 + 被调试器停住」这个状态里。醒过来,WCDB 一做完整性检查,发现写坏了,直接弹「数据库已损坏」。
这不是理论。这个弹窗大概每隔几天出现一次,只在我的工具跑过之后出现,查了很久才定位到是常驻附着撞上睡眠。后来加了两道缓解:空闲一段时间自动 detach,睡眠前用电源事件回调抢先 detach。弹窗少了很多,但只要附着这件事还在,窗口就还在。
我想要的是根上没有这个窗口。
转念:断点其实只干了一件事
盯着这条链看久了,我发现一个事:整条发送里,调试器唯一不可替代的作用,就是「在对的时刻往那个字段写几个字节」。打字是 CGEvent 干的,发送是微信自己干的,都不需要调试器。我之所以挂断点,只是为了拿到一个写内存的时机。
那把「写内存」这一下换掉就行了。
macOS 上有一条不走 ptrace 的路:task_for_pid 拿到目标进程的 task port,然后 mach_vm_write 直接往它的内存里写。进程不停、不被拴住、不进 traced-stop。这正是我堆扫描读图片明文时已经在用的同一套 mach 调用,只是那时候只读,现在要写。
先验证读:我用 mach 去读那个收件人字段,读回来是「filehelper」(文件传输助手的内部 id),正是当时聚焦的聊天。说明 mach 能读到、字段位置也对。
再验证写:往同一个字段写回「filehelper」,读回来确认落了,微信状态正常,没崩没卡。写得进去,而且不附着。
还有个时机问题:我 mach 写进去的收件人,能不能撑到微信那条指令来读?我写了个不一样的标记进去,等了三秒再读,值原封不动——微信不会自己去覆盖这个字段。而真实发送里「回车到微信读字段」只隔几毫秒,远远短于三秒。时机稳赢。
到这一步,三个本来以为很难的问题全没了:不用从零重建一条新发送路径,不用对付指针签名,不用想办法把调用塞回主线程。我只是把老链子里那一下 LLDB 写内存,原地换成 mach 写内存。
卡住的地方:不挂断点,怎么知道收件人字段在哪
mach 写内存有个前提:你得知道往哪个地址写。老办法里这个地址是断点命中时从寄存器里现拿的——可现在我不挂断点了。
我先以为这是个硬骨头。那个会话对象在堆上,地址每次都不一样,得有个稳定的锚点才能定位。我扫了静态全局区,没有;我扫了整个堆找指向它的指针,扫出来九十多个引用,可哪个是「当前会话管理器」那个会跟着焦点变的槽位,分不出来。
要分清楚,得做个对照实验:焦点停在聊天 A 时记一组,切到聊天 B 再记一组,值变了的那个槽位就是管理器。切焦点只能动微信界面,而我给自己定的死规矩是不碰微信 UI,所以这一下得真人来点。
我请人手动在微信里切了个聊天,然后重读那九十多个地址——一个都没变。
这反而点醒了我。我一直在找「会变的指针」,可如果会话对象本身地址不变呢?我去读那个收件人字段:之前是 filehelper,切聊天之后变成了新聊天的 id。对象地址没动,只是它里面那个字段被原地改写了。
也就是说,「当前聚焦的会话」是一个地址固定的单例对象。你在微信里切来切去,这个对象一直在那儿,变的只是它内部的收件人字段。我根本不用追踪什么会变的指针,也不用找管理器锚点——把这个稳定地址记下来就够了。

新办法:播种一次,之后全程不附着
定下来的方案是这样:
- 每次微信启动后,头一条消息还是走老路——LLDB 挂断点发一次,顺手把那个稳定的会话地址记进缓存,且必须等这条真的落库验证通过,才认这个地址;
- 记完地址,立刻 detach。微信从此脱离 ptrace;
- 之后每一条,都走 mach:往缓存的地址写收件人,CGEvent 打字回车,发出去。全程没有任何调试器碰微信;
- 万一缓存失效(地址读出来不对了),自动作废重来一次老路。LLDB 永远是兜底,绝不会因为新路出岔就把消息默默丢掉。
跑出来的样子:第一条消息断点命中、落库验证、detach,微信不再被调试;之后的消息,断点一次都不命中,微信进程从头到尾是正常运行态,消息照常落库。那个会弹「数据库已损坏」的常驻附着窗口,没了。
我亲手把微信干掉的那一次
改造过程里我栽了个跟头,值得单拎出来说。
我那时要重启守护进程换新二进制,顺手 pkill 把它强杀了。守护进程下面挂着一个 LLDB,而那个 LLDB 正附着在微信上。kill -9 不给任何收尾机会,被强杀的 LLDB 没来得及干净 detach——而 ptrace 有个要命的规则:强杀一个正附着的调试器,被它调试的进程会跟着一起死。微信当场没了。
这正是我整篇在根治的那个 ptrace 风险,只不过这次不是睡眠触发,是我自己手贱触发的。
救回来靠两件事:微信的本地库走的是 SQLite WAL 模式,WAL 本身是为崩溃设计的,重启会回放日志把没写完的事务补上;我 open 把微信在后台重新拉起来,数据库能正常读,没真损坏。
教训记死了:重启守护进程只能发干净的退出信号,让它有机会先 detach,永远不许 kill -9。干净信号最坏只会让微信卡一下,能解;-9 会直接要命。这跟「不许强杀微信」是同一条规则的变体——我没直接杀微信,我杀了挂在它身上的调试器,结果一样。
还差最后一块,但根已经断了
现在每次微信启动还得用 LLDB 发一条来播种那个地址,之后才全程 mach。要做到「第一条都零附着」,得再逆出一个能不附着就定位聚焦会话地址的锚点——大概率要靠硬件 watchpoint 抓微信设置当前聊天的那条写指令。这是后话,可做可不做。
但稳态已经达成:绝大多数消息走 mach,微信不被任何调试器碰,那个会弹「数据库已损坏」的窗口在根上消失了。一条本来要靠「挂调试器 + 在对的时刻改内存」的发送链,被拆成了「记一个稳定地址 + 往里写几个字节 + 模拟键盘」,而写字节这一下不再需要把微信拴在 ptrace 上。
回头看,真正解题的不是某个 mach 调用,是那句「断点到底干了什么」。一旦把它从「我需要一个调试器」缩成「我需要在对的时刻写几个字节」,后面的路就只是工程了。

微信
支付宝
评论
评论发布后会立即公开,如触发规则可能被审核下架。