arm64 无头手机 QQ 机器人部署全记录(Milky + 本地 sign)

日期:2026-11-05 目标机:meilan-phone1(arm64 无头安卓机,Debian 13 chroot)
结果:arm64 本地全链路跑通(sign server + Milky + agent),无远程依赖、无硬性 x86 依赖。


TL;DR

  1. 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),无需在手机上编译
  2. 为什么"电脑能跑、手机跑不通":不是能力问题,是 arm64 的 sign offset 没人逆向过。x86_64 的 offset(0x56bd087)早有人找出,arm64 的是本次新逆向的。
  3. 核心技巧:用 dladdr 调用点做锚点定位 sign 函数(sign 函数独有的自省调用 + 5 参数保存模式),比硬编码字节码 pattern 可靠——后者是 x86 指令,arm64 搜不到。
  4. 部署产物总体积 ~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 早有人逆向(0x56bd087 for 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 调用。步骤:

  1. 拿 arm64 的 dladdr@plt 地址:aarch64-linux-gnu-objdump -d -j .plt → dladdr@plt = 0x743c70
  2. 扫描整个 .text 找所有 bl 0x743c70(python 扫 4 字节对齐,(instr & 0xFC000000) == 0x94000000 且目标匹配)
  3. 命中 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>
    
  4. 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 报缺 ICUDebian 13 没装 libicuapt install libicu-dev
node require wrapper.node 报缺 libvips-cpp.so.42QQ 打包了 sharpapt install libvips42t64
报缺 libunwind-aarch64.so.8Debian 13 包名apt install libunwind8
symbol lookup error: qq_magic_napi_registerwrapper.node 需要该符号编译空 stub libsymbols.so(gcc)
Module did not self-registerN-API 未注册(wrapper.node 靠外部 qq_magic_napi_register 注册)走 ctypes(绕过 N-API),或注入 Electron(不推荐)
getThirdPartySigByUin needs 6 arguments / implementation not validN-API 直调需 session 绑定,纯 node 下 session 初始化必 segfault(Electron 插件)放弃 N-API 直调,用 ctypes + offset
objcopy: Unable to recognise ... arm64NativeAOT 交叉编译用系统 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 → agent scanLog() 正则抓 URL
  • 状态:页面 /milky 打开时 setInterval(3000) 轮询 /api/milky/status;agent 后台无长驻轮询(autoEnsure() 仅启动时拉一次)
  • 开销:二维码是低频一次性事件,3s 轮询只在用户开着页面时存在,可忽略

为什么事件驱动走不通

  1. Milky 的事件分发是源码生成器驱动的:Lagrange.Milky.Generator/Events/EventExtensionGenerator.cs 扫描所有 IEventConverter<TEvent> 实现,生成 RegisterConvertibleEvents + AddEventConverters(见 obj/.../generated/EventExtension.g.cs)。只有生成的注册列表里的事件才会被 WebHook/WebSocket/SSE 转发。
  2. 内置只有 12 个 converter(Lagrange.Milky/Events/Converters/):bot_offline、群/好友/消息/recall/reaction/nudge 等。
  3. 关键缺漏:BotQrCodeEvent(二维码)、BotOnlineEvent(上线/登录成功)、BotLoginEvent(登录)没有 converter → WebHook/WebSocket 收不到这些事件(handler 里 GetRequiredService<IEventConverter<TEvent>>() 直接抛异常)。
  4. 要让二维码/上线推给 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
  5. 代价 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>" } }
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐