约束条件
reMarkable Paper Pro Move 出厂系统的核心阅读程序是 xochitl,闭源二进制,没有官方扩展机制。项目的第一条约束从一开始就锁死了:不修改 xochitl 本体(不改二进制、不重打包)。唯一可用的注入点是 xovi——一个基于 LD_PRELOAD 的运行时扩展加载框架,扩展以 .so 形式放进 extensions.d/,由 xovi 在进程启动时扫描加载。
这个约束决定了整条技术路径:Ghidra 静态逆向定位 hook 点 → 交叉验证反编译结果 → 写 hook → 真机验证。没有源码、没有符号表(部分有部分没有)、没有 API 文档。
hook 点定位的两条硬规矩
第一条规矩来自一次真实事故(内部记录称 "Step S"):某个 hook 点的内存布局是照着 Ghidra 反编译出的 C 代码直接写的,没有做只读验证,上真机后崩溃。此后的规矩是:
新的写内存/写属性场景,反编译结果看起来结构再合理,也要先加只读日志确认真实内存布局,再动写操作。
第二条规矩:反编译器在参数个数、栈偏移上出过错,关键 hook 点用原始反汇编(ldr/str/mov 指令序列)交叉核对,不单信反编译后的 C 伪码。
有 Python 参照实现的算法(比如荧光笔吸附涉及的分词逻辑),流程固定为:先写 Python 原型,跑穷举/边界差分测试跟已知正确输出逐字节对拍,测试通过后再移植成 C。规模变化时(比如从测试用小样本换成真实词库规模)要重跑一遍差分测试,因为小规模用例不会暴露大规模才出现的边界情况。
部署纪律:备份、校验、健康检查
每次改设备行为的部署固定三步:
# 1. 备份现有二进制(命名带上这次改动的标识,方便回滚定位)
cp cangjie-langhook.so /home/root/cangjie-backups/cangjie-langhook.so.bak.pre-<改动名>
# 2. 部署后核对 md5 一致
md5sum cangjie-langhook.so # 本地
ssh root@device md5sum /path/to/cangjie-langhook.so # 设备
# 3. 重启后做健康检查,不是"看起来没崩"就算过
systemctl restart xochitl
systemctl is-active xochitl # 期望 active
systemctl show xochitl -p MainPID # 确认 PID 变了(真的重启了,不是没生效)
systemctl show xochitl -p NRestarts # 期望没有异常增长(没有陷入重启循环)有一条备份纪律是用崩溃循环换来的:备份文件绝不能留在 extensions.d/ 目录里。xovi 扫描这个目录时不看文件后缀,任何文件都会被尝试当扩展加载;一旦备份文件恰好是同一个扩展的旧版本,会触发 "processed more than once" 的重复注册错误,xochitl 直接 exit 1,systemd 拉起 → 再崩溃 → 触发 StartLimitAction,最终整机重启。
也是因为这次事故,Python 层的差分测试和"host 单测不能替代真机"这条原则被反复强调——真机上有过多次跟 host 假实现不一致的案例,比如 QArrayData::allocate(0) 在真机上返回 NULL,host 侧的假实现返回非空指针。文档里写"真机验证通过",指的必须是真的在设备上跑过、看到预期日志或行为的记录,不是"逻辑上应该没问题"。
两次把设备写死的事故
事故一:写 /usr 触发 dm-verity A/B 回滚。 早期 xovi 启动配置放在了 /usr/lib/systemd/system/xochitl.service.d/zz-cangjie-xovi.conf。/usr 挂载在 rootfs(ext4,只读+dm-verity 完整性校验),当时选它是因为"重启不丢"。写了两次之后,dm-verity 判定该分区完整性被破坏,触发 A/B 回滚——设备自动切到另一个分区,等效变砖。此后这条路径被彻底放弃,现行机制改成完全不碰 /usr:/home/root/xovi/start 在开机时把 /home 下持久保存的配置片段整份拷进新挂载的 /etc(tmpfs overlay,重启即清空但不受 dm-verity 保护),再 daemon-reload + 重启对应 unit。
事故二:电池采样器触发 cgroup/RCU 死锁。 battop 是一个后台电池数据采样器,用 systemd timer 每约 10 分钟触发一次。某次触发点恰好落在设备刚从休眠唤醒的窗口,采样逻辑跟内核的 cgroup/RCU 子系统产生死锁——现象是完全冻屏,ping 和 SSH 都不通,但 USB 链路层(LOWER_UP)仍然亮着,说明不是断电,是内核硬卡死。唯一解法是长按电源键 25-30 秒强制重启。事后复盘:判据不是"这段代码逻辑对不对",而是"这段代码会在系统的哪个调度窗口执行、跟哪些内核子系统的状态机产生交叉"——后台定时任务尤其要考虑设备刚唤醒这类瞬态窗口。
一个真实的架构决策:从"环境变量开关"到"源码级独立"
荧光笔 CJK 精确吸附最初是 chinese-ime/langhook 这个大扩展里的一个 hook,跟拼音输入法共享同一个 .so。有次发现这个 .so 从设备上消失了,第一版修复方案是加一个运行期环境变量开关:
# xovi/services/xochitl.service/cangjie-langhook.conf
Environment=CANGJIE_IME_HOOKS=0 # =0 时跳过拼音输入法等 8 个 hook,只留荧光笔吸附这个方案真机验证通过了(journal 里能看到对应日志),但被否掉了——原因不是技术上不可行,而是它仍然让"荧光笔吸附"和"拼音输入法"共享同一份构建产物、同一段源码,任何对输入法部分的改动理论上都可能影响吸附功能的构建结果。最终方案是源码级独立:把吸附逻辑抽成 hl-snap/,独立的 .c 文件、独立的 .xovi 元数据、独立的 Makefile、独立的部署脚本,产出一个完全不同名字(hl-snap.so)、不同 _xovi_shouldLoad 判据的扩展。两者之间除了"逻辑最初抄自同一处、共用几个工具函数文件"之外,构建期和部署期零耦合。环境变量开关那版改动被整段 revert,chinese-ime/langhook 恢复到改动前一字节不差。
这个决策后来变成一条通用原则:功能边界模糊时,先问"这是不是应该是两个独立产物",而不是先问"能不能加个开关"。
下篇:一个精确到"恰好 60 秒"的 WiFi 掉线排查案例、手写识别的 anchor 管线设计、以及一次仓库级别的重构。