2026-11-05-arm64无头手机QQ机器人部署全记录
arm64 无头手机 QQ 机器人部署全记录(Milky + 本地 sign)
日期:2026-11-05 目标机:
meilan-phone1(arm64 无头安卓机,Debian 13 chroot)
结果:arm64 本地全链路跑通(sign server + Milky + agent),无远程依赖、无硬性 x86 依赖。
TL;DR
- arm64 手机上跑 QQ 机器人完全可行,没有硬性 x86_64 依赖:
- 官方 QQ Linux 有 arm64 包(
im.qq.com/linuxqq/support.html明确支持 x64 + arm64) wrapper.node(sign 核心)从官方 arm64 deb 提取即得- 逆向出 arm64 sign offset =
0x38bf4fc,用 ctypes 直调(与 x86_64 同一机制) - Milky 本地交叉编译 arm64(NativeAOT),无需在手机上编译
- 官方 QQ Linux 有 arm64 包(
- 为什么"电脑能跑、手机跑不通":不是能力问题,是 arm64 的 sign offset 没人逆向过。x86_64 的 offset(
0x56bd087)早有人找出,arm64 的是本次新逆向的。 - 核心技巧:用
dladdr调用点做锚点定位 sign 函数(sign 函数独有的自省调用 + 5 参数保存模式),比硬编码字节码 pattern 可靠——后者是 x86 指令,arm64 搜不到。 - 部署产物总体积 ~1.16G(Milky 74M + sign 143M + dotnet 654M + node 287M),磁盘剩 21G,无压力。
一、环境
| 项 | 值 |
|---|---|
| 目标机 | meilan-phone1,aarch64,1 核,2.7G 内存,25G eMMC |
| 系统 | Debian 13 (trixie) chroot(magisk-chroot-boot,root) |
| 内核 | 3.18.35(2018 安卓底层) |
| 已有服务 | supervisord:sshd / cpa / freellmapi:3001 / openlist / 9router / picoclaw:18800 |
| 开发机 | x86_64(192.168.31.204),dotnet SDK 10.0.403 |
| QQ 协议端 | Lagrange Milky(.NET NativeAOT,net10.0) |
| 签名服务 | ntqq-sign-server/sign.py(ctypes 直调 wrapper.node) |
| 机器人 | chat-clone/agent(tsx 直跑,OneBot v11,AGENT_CHANNEL=milky) |
⚠️ load average ~10 是假象:安卓底层驱动内核线程(fuse_log / ccci_fsm / disp_idlemgr / bat_routine)堆在 D 状态等硬件,实际 CPU idle 84%,算力不紧张。
二、核心突破:逆向 arm64 sign offset
背景:为什么需要 offset
sign.py 用 ctypes 直调 wrapper.node 里的裸签名函数:
func_addr = module_base + offset # dl_iterate_phdr 找基址 + 偏移
SignFunc = CFUNCTYPE(c_longlong, c_char_p, POINTER(c_ubyte), c_uint, c_int, POINTER(c_ubyte))
- x86_64:offset 早有人逆向(
0x56bd087for 3.2.26;KNOWN_OFFSETS表里有 3.2.27/19/18),所以电脑能跑。 - arm64:表里没有,且
find_offset_by_pattern的字节码 pattern(29 f2 4c 8d 44 24= x86 的sub edx,esi+lea r8,[rsp+..])在 arm64 指令流里物理上不存在(arm64 指令固定 4 字节、编码完全不同)→ 自动检测失败。
锚点法(本次使用):dladdr 调用点
x86_64 的 sign 函数(0x56bd087)反汇编发现它独有地调用 dladdr@plt(极少函数调用它)。同一份源码的 arm64 版必然也有同样的 dladdr 调用。步骤:
- 拿 arm64 的
dladdr@plt地址:aarch64-linux-gnu-objdump -d -j .plt→dladdr@plt = 0x743c70 - 扫描整个
.text找所有bl 0x743c70(python 扫 4 字节对齐,(instr & 0xFC000000) == 0x94000000且目标匹配) - 命中 4 处;逐一反汇编比对,符合 5 参数保存模式的那处即 sign 函数:
38bf4fc: stp x29,x30,[sp,#-96]! ; 函数开头 38bf520: str w3,[sp,#76] ; 保存 seq (第4参) 38bf524: str x0,[sp,#56] ; 保存 cmd (第1参) 38bf52c: mov x20,x1 ; 保存 src (第2参) 38bf53c: mov x19,x4 ; 保存 out (第5参) 38bf540: mov w24,w2 ; 保存 len (第3参) 38bf55c: mov x0,x30 ; dladdr 参数 = 返回地址(自省) 38bf560: bl 743c70 <dladdr@plt> - arm64 sign offset =
0x38bf4fc(函数开头 VMA,PIE 下即相对基址偏移)
arm64 ABI 参数在 x0-x4(cmd, src, len, seq, out),正好匹配
SignFunc——ctypes 的CFUNCTYPE会自动用本地 ABI,无需改调用约定。
验证
# 手机端
cd /opt/qq-sign && python3 sign.py --wrapper /opt/qq-sign/wrapper.node \
--port 8090 --version 3.2.35-54395 --offset 0x38bf4fc
# → self-test ok: sign[:16]=1C210988408864F3...
# → curl POST /api/sign/sec-sign → {"code":0,"value":{"sec_sign":"7BEC644C...","sec_token":"","sec_extra":""}}
三、完整部署手册(可复现)
0. 手机准备(一次性)
# 确认 aarch64 + Debian 13
ssh meilan-phone1 'uname -m && cat /etc/os-release | grep PRETTY'
1. 装 dotnet(跑/编 Milky 用)
# 官方脚本装 SDK 10 arm64(232MB),Debian 13 需 libicu
curl -sSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
bash /tmp/dotnet-install.sh --channel 10.0 --architecture arm64 --install-dir /usr/share/dotnet
ln -sf /usr/share/dotnet/dotnet /usr/local/bin/dotnet
apt-get install -y libicu-dev # dotnet --info 报缺 ICU 时
# 验证
dotnet --info # SDK 10.0.403 / runtime 10.0.14
2. 提取 arm64 wrapper.node(官方 deb)
# 官方下载(直连 qqdl 常被墙,用 GitHub 镜像 libzonda/Linux-QQ-release)
curl -L -o QQ_3.2.35_261103_arm64_01.deb \
https://github.com/libzonda/Linux-QQ-release/releases/latest/download/QQ_latest_arm64_01.deb
dpkg-deb -x QQ_3.2.35_261103_arm64_01.deb extract
# 需要的文件(都在 resources/app/ 下):
# wrapper.node 123MiB ← sign 核心(aarch64 ELF)
# libcrbase.so 14.8MB
# libbugly.so 5MB
# sharp-linux-arm64v8.node 235KB(备用)
scp wrapper.node libcrbase.so libbugly.so sharp-linux-arm64v8.node meilan-phone1:/opt/qq-sign/
3. 手机补依赖 + 搭 sign server
# wrapper.node 可加载所需的库(Debian 13 包名)
apt-get install -y libvips42t64 libunwind8 gcc
# 编译 ctypes 用的符号桩(空 stub,满足 wrapper.node 的未定义符号)
echo 'void qq_magic_napi_register(void* p) { }' > /tmp/sym.c
gcc -std=c99 -shared -fPIC -o /opt/qq-sign/libsymbols.so /tmp/sym.c
# 启动 sign server(长驻)
cd /opt/qq-sign && setsid nohup python3 sign.py \
--wrapper /opt/qq-sign/wrapper.node --port 8090 \
--version 3.2.35-54395 --offset 0x38bf4fc \
> /tmp/sign-arm64.log 2>&1 < /dev/null & disown
# 验证
curl -s http://127.0.0.1:8090/health
⚠️ libsymbols.so 必须用空 stub(
{}),不能用转发napi_module_register的"真实注册版"——那个符号只在 node 进程里有,ctypes/python 进程里没有,会导致symbol lookup error: qq_magic_napi_register。
4. 交叉编译 arm64 Milky(NativeAOT)
# 开发机(x86_64),需 clang + aarch64 sysroot(Debian:gcc-aarch64-linux-gnu)
# 关键坑:系统 objcopy 不认识 arm64 → 必须指定交叉 objcopy
cd /mnt/shared/qqbot/Lagrange.Core/Lagrange.Milky
dotnet publish -c Release -r linux-arm64 -p:ObjCopyName=aarch64-linux-gnu-objcopy
# 产物:bin/Release/net10.0/linux-arm64/publish/Lagrange.Milky(aarch64 ELF,76.8MB)
appsettings.json(publish 里没有,是嵌入资源,需外置同名文件覆盖):
{ "Lagrange": { "Protocol": { "Signer": { "BaseUrl": "http://127.0.0.1:8090/api/", "Token": "" } },
"Login": { "Uin": 3837835939, "AutoReLogin": true } },
"Milky": { "HttpServer": { "Host": "127.0.0.1", "Port": 3000 } } }
scp Lagrange.Milky appsettings.json meilan-phone1:/opt/qqbot/
# 启动
ssh meilan-phone1 'cd /opt/qqbot && setsid nohup ./Lagrange.Milky > /tmp/milky-arm64.log 2>&1 < /dev/null & disown'
# 出登录 URL = sign 链路 OK
grep -oE "URL: https://[^ ]+" /tmp/milky-arm64.log
5. 部署 agent(chat-clone)
# 所有 native 依赖都用预编译包,零编译:
# @node-rs/jieba → npm 自动拉 linux-arm64 prebuilt(不编译)
# vendor/vec0.so → sqlite-vec 官方 prebuilt(asg017/sqlite-vec v0.1.9 loadable-linux-aarch64)
curl -L -o sqlite-vec-arm64.tar.gz \
https://github.com/asg017/sqlite-vec/releases/download/v0.1.9/sqlite-vec-0.1.9-loadable-linux-aarch64.tar.gz
tar xzf sqlite-vec-arm64.tar.gz # 得 vec0.so(aarch64)
# 打包 agent(排除 node_modules / .env.bak*),替换 vec0.so,改 .env 后传手机
cd /mnt/shared/my-project/chat-clone
tar czf /tmp/agent-phone.tar.gz --exclude=node_modules --exclude='.env.bak.*' agent
scp agent-phone.tar.gz meilan-phone1:/opt/ && \
ssh meilan-phone1 'mkdir -p /opt/chat-agent/data && tar xzf /opt/agent-phone.tar.gz -C /opt/chat-agent'
.env 三处必须改(手机路径 + 网络):
- RAG_DB_PATH=/mnt/shared/.../qwen_persona_rag.sqlite
+ RAG_DB_PATH=/opt/chat-agent/data/qwen_persona_rag.sqlite
- VEC0_SO=/mnt/shared/.../vendor/vec0.so
+ VEC0_SO=/opt/chat-agent/vendor/vec0.so
- EMBED_PROXY=http://192.168.31.213:7890 # 手机连不上 x86 服务器的 7890 代理
+ EMBED_PROXY= # 改直连
(AGENT_CHANNEL=milky、MILKY_HOST=127.0.0.1、MILKY_PORT=3000 已配好,连手机本地 Milky。)
# 手机装依赖(53 packages,19s,零编译)
ssh meilan-phone1 'cd /opt/chat-agent && npm install --no-audit --no-fund'
# 传 rag 库(220M)
scp qwen_persona_rag.sqlite meilan-phone1:/opt/chat-agent/data/
# 启动 agent
ssh meilan-phone1 'cd /opt/chat-agent && setsid nohup npm start > /tmp/chat-agent-arm64.log 2>&1 < /dev/null & disown'
# 验证:WebUI 127.0.0.1:8085 起来;连 Milky 报 ECONNREFUSED 是正常(等登录后自动重连)
6. 登录(最后一步)
Milky 出 https://txz.qq.com/p?k=...&f=1600001615,在手机 QQ 客户端里打开(电脑浏览器会跳官网,无效)。
- 必须勾选「下次登录无需确认」(信任设备)
- URL ~120s 过期,过期 Milky 崩(
CodeExpired),重启./Lagrange.Milky重出码
四、踩坑清单(现象 → 原因 → 解决)
| 现象 | 原因 | 解决 |
|---|---|---|
dotnet --info 报缺 ICU | Debian 13 没装 libicu | apt install libicu-dev |
node require wrapper.node 报缺 libvips-cpp.so.42 | QQ 打包了 sharp | apt install libvips42t64 |
报缺 libunwind-aarch64.so.8 | Debian 13 包名 | apt install libunwind8 |
symbol lookup error: qq_magic_napi_register | wrapper.node 需要该符号 | 编译空 stub libsymbols.so(gcc) |
Module did not self-register | N-API 未注册(wrapper.node 靠外部 qq_magic_napi_register 注册) | 走 ctypes(绕过 N-API),或注入 Electron(不推荐) |
getThirdPartySigByUin needs 6 arguments / implementation not valid | N-API 直调需 session 绑定,纯 node 下 session 初始化必 segfault(Electron 插件) | 放弃 N-API 直调,用 ctypes + offset |
objcopy: Unable to recognise ... arm64 | NativeAOT 交叉编译用系统 objcopy 处理不了 arm64 | -p:ObjCopyName=aarch64-linux-gnu-objcopy |
直连 qqdl.gtimg.cn 下载 0 字节 | 被墙 | 用 GitHub 镜像 libzonda/Linux-QQ-release |
手机连 192.168.31.213:7890 无响应 | x86 服务器代理端口手机不可达 | EMBED_PROXY= 改直连 |
load average 10 | 安卓底层驱动内核线程 D 状态(假象) | 无视,看 %Cpu idle |
五、关键命令速查
# 逆向锚点(在开发机)
aarch64-linux-gnu-objdump -d -j .plt wrapper-arm64.node | grep dladdr # 拿 dladdr@plt
# python 扫 .text 找 bl dladdr(见上文脚本),反汇编比对 5 参数保存 → offset
# 手机 sign server(长驻)
cd /opt/qq-sign && setsid nohup python3 sign.py --wrapper /opt/qq-sign/wrapper.node \
--port 8090 --version 3.2.35-54395 --offset 0x38bf4fc \
> /tmp/sign-arm64.log 2>&1 < /dev/null & disown
# 手机进程三件套
pgrep -af "sign.py|Lagrange.Milky|tsx index" # sign / milky / agent
tail -20 /tmp/sign-arm64.log /tmp/milky-arm64.log /tmp/chat-agent-arm64.log
六、产物位置
| 项 | 路径(手机) |
|---|---|
| sign 服务 | /opt/qq-sign/(wrapper.node 123M + sign.py + libsymbols.so + 3 .so) |
| Milky | /opt/qqbot/(Lagrange.Milky 76.8M + appsettings.json) |
| agent | /opt/chat-agent/(源码 + node_modules + data/qwen_persona_rag.sqlite 220M) |
| 运行日志 | /tmp/{sign-arm64,milky-arm64,chat-agent-arm64}.log |
注:若想省 654M,可卸载手机 dotnet SDK(跑 Milky 不需要 SDK,只需 runtime;但以后重编需要)。当前 SDK 留着便于手机上自编。
七、难点:Milky 为什么用「轮询」而不是「事件驱动」
结论先说:当前用进程级 + 日志轮询,刻意不做事件驱动。原因:Milky 的事件推送(WebHook/WebSocket/SSE)只转发注册了 converter 的事件类型,而二维码/上线/登录事件没有 converter——要做事件驱动必须改上游源码 + 重新交叉编译 + 反复部署覆盖 eMMC,代价超过收益,且反复刷 eMMC 会刷死老设备。
现状方案(轮询,零源码改动)
- 进程级:
pid()精确匹配^/opt/qqbot/Lagrange.Milky$+start()/stop()/restart()(setsid nohup,dash 下不能disown) - 二维码获取:milky 启动 →
Login()抛BotQrCodeEvent→LoginService.OnQrCode打印URL: https://txz.qq.com/p?k=...到/tmp/milky-arm64.log→ agentscanLog()正则抓 URL - 状态:页面
/milky打开时setInterval(3000)轮询/api/milky/status;agent 后台无长驻轮询(autoEnsure()仅启动时拉一次) - 开销:二维码是低频一次性事件,3s 轮询只在用户开着页面时存在,可忽略
为什么事件驱动走不通
- Milky 的事件分发是源码生成器驱动的:
Lagrange.Milky.Generator/Events/EventExtensionGenerator.cs扫描所有IEventConverter<TEvent>实现,生成RegisterConvertibleEvents+AddEventConverters(见obj/.../generated/EventExtension.g.cs)。只有生成的注册列表里的事件才会被 WebHook/WebSocket/SSE 转发。 - 内置只有 12 个 converter(
Lagrange.Milky/Events/Converters/):bot_offline、群/好友/消息/recall/reaction/nudge 等。 - 关键缺漏:
BotQrCodeEvent(二维码)、BotOnlineEvent(上线/登录成功)、BotLoginEvent(登录)没有 converter → WebHook/WebSocket 收不到这些事件(handler 里GetRequiredService<IEventConverter<TEvent>>()直接抛异常)。 - 要让二维码/上线推给 agent,必须:
- 写
BotQrCodeEventConverter(Name="bot_qrcode",Data{url, image(base64 PNG)})+BotOnlineEventConverter(Name="bot_online",Data{reason}),标[EventConverter]→ 生成器自动注册(已验证可行:编译后.g.cs自动多了BotQrCodeEvent/BotOnlineEvent的 RegisterEvent + DI) - 配置
Milky:Event:WebHook:Enabled=true, TargetUrls=["http://127.0.0.1:8085/api/milky/events"] - agent 加
POST /api/milky/events接收MilkyEvent {EventType, Time, SelfId, Data}(EventType=converter.Name,snake_case) - 重新交叉编译 NativeAOT arm64(
-p:ObjCopyName=aarch64-linux-gnu-objcopy)+ scp 覆盖 76MB 二进制到 eMMC
- 写
- 代价 vs 收益:改上游源码 + 每改一次都交叉编译 + 每部署一次都写 76MB 到 eMMC → 反复刷写会刷死老设备(用户指令:部署一次很重,别反复刷)。而收益仅是"二维码/登录状态自动推送",对低频一次性登录场景不值得。故保留轮询。
若将来要做事件驱动(可行路径存档)
- converter 落点:
Lagrange.Milky/Events/Converters/BotQrCodeEventConverter.cs+BotOnlineEventConverter.cs([EventConverter]+IEventConverter<TEvent, Data>,ConvertAsync返回{url,image}/{reason}) - 登录成功广播点:
Lagrange.Core/Internal/Logic/WtExchangeLogic.cs:392→PostEvent(new BotOnlineEvent(Reasons.Login)) BotQrCodeEvent结构:{ string Url, byte[] Image }(PNG 字节;LoginService.OnQrCode写qrcode.png+ 日志打URL:行,即轮询方案的抓取来源)- WebHook 回调体:
{ "EventType": "bot_qrcode", "Time": 1700000000, "SelfId": 3837835939, "Data": { "url": "...", "image": "<base64 png>" } }
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)