这篇文章记录一次把 .NET Web 程序跑到小米手机上的完整过程:从 Termux、Ubuntu 子系统,到 linux-arm64 发布、glibc 依赖、Server GC 静默崩溃排查。
之前就想试试把 .NET Web 程序塞进手机里跑,最近刚好有这个需求,也终于抽出时间把流程走通了。严格来说,这不是一个适合正式生产的方案,更像是极客玩法、临时服务、局域网小工具或者验证环境。
如果你的目标是认真做移动端应用,我还是建议优先考虑 .NET MAUI。本文讲的是另一件事:通过 Termux 部署一个相对完整的 Ubuntu 用户态环境,然后运行基于 linux-arm64 编译出来的 .NET 独立部署程序。
这里有几个前提需要先说清楚:
linux-arm64。关于 AOT 编译,可以参考我之前写过的文章:.NET 10 Native AOT 在 Linux 嵌入式设备上的实战。
俗话说,工欲善其事,必先利其器。开始前我们需要先在电脑上把 .NET Web 项目发布成 linux-arm64,然后在手机上安装 Termux,并配置好权限和 SSH。
如果你已经有 .NET Web 项目,可以先在电脑上完成发布,再把产物传到手机里。这里以 Docker 编译 Native AOT 为例,核心就是把 Runtime Identifier 换成 linux-arm64:
docker run --rm -v "$(pwd):/app" -w /app mcr.microsoft.com/dotnet/sdk:10.0 bash -c " \
apt-get update -qq && \
apt-get install -y -qq clang gcc-aarch64-linux-gnu lld && \
dotnet publish ./bweb/bweb.csproj -c Release -r linux-arm64 \
-p:PublishAot=true \
-p:InvariantGlobalization=true \
-p:LinkerFlavor=lld -p:ObjCopyName=aarch64-linux-gnu-objcopy \
-o ./dist-aot"
如果不做 AOT,也可以发布 Self-contained 模式。重点不是一定要 AOT,而是发布目标必须和手机 CPU 架构一致,否则后面排查会非常痛苦。
Termux 不建议从应用商店下载,最好去 Termux GitHub Releases[1] 下载最新版安装包。现代手机一般选择带 arm64-v8a 字样的 APK。
以小米 / 澎湃 OS 为例,安装之后不要急着开始敲命令,先去系统设置里把权限放开。否则你后面可能会遇到程序突然被杀、后台服务断掉、文件访问异常等问题。
需要重点检查这几类权限:

应用设置
这里有个小坑:一般教程会告诉你,用 termux-setup-storage 命令给 Termux 授权访问手机存储。但在小米 / 澎湃 OS 上,这个命令可能会报错,提示 Aborted。实际上,这个报错不影响后续操作,只要系统权限已经给了,并且 ls /sdcard 能看到手机存储,就可以继续走。
~ $ termux-setup-storage
/data/data/com.termux/files/usr/bin/termux-setup-storage: line 29: 12604 Aborted
am broadcast --user 0 --es com.termux.app.reload_style storage -a com.termux.app.reload_style com.termux > /dev/null
第一次打开 Termux 时,先等初始化安装完成。之后建议第一时间配置 SSH,这样电脑端连接进去操作会舒服很多。
先切换国内软件源。在 Termux 里执行:
termux-change-repo

Termux 换源
弹出的蓝色界面里,在 Mirror group 中选 Mirrors in Chinese Mainland,按空格选中后回车确认。
然后安装并启动 OpenSSH:
pkg install openssh -y
安装完成后,启动 SSH 服务,并进行一些连接的准备工作:
sshd
whoami
passwd

Termux SSH 准备
whoami 会输出当前 Termux 用户名,一般类似 u0_a123。passwd 用来设置 SSH 登录密码,输入时不会显示字符,正常盲打两次即可。
接着在手机里查看局域网 IP:
ifconfig
找到 wlan0 对应的地址,比如 192.168.x.x。电脑和手机在同一个 Wi-Fi 下时,就可以这样连接:
ssh -p 8022 你的用户名@你的手机IP
接下来把电脑上编译好的 .NET 发布包传到手机。可以用微信、QQ 先传到下载目录,也可以用 Xshell、MobaXterm、Termius 这类工具直接拖拽上传。
不过实际运行前,建议把程序复制到 Termux 的 home 目录下再解压、授权和执行。不要直接在下载目录里折腾执行权限,有些手机系统底层的防护会吞指令,导致添加执行权限异常或失败。
这里我们已经可以在电脑远程SSH到手机的 Termux 里了,接下来就可以尝试运行 .NET Web 程序了。
我一开始把 linux-arm64 的程序放进去,解压、授权都没问题,但执行时还是报错:
~/bweb $ ./bweb
bash: ./bweb: cannot execute: required file not found
如果你之前发布错了架构,还可能看到类似这样的错误:
~ $ ./bweb
Could not open ELF interpreter /lib/ld-linux-armhf.so.3: Error 2 (No such file or directory)
换成 linux-arm64 后,armhf 的问题没有了,但 cannot execute: required file not found 仍然存在。这里“找不到”的不是 bweb 文件本身,而是它启动时需要的 Linux 底层运行环境,通常是标准 Linux 的 C 运行库 glibc,以及对应的 ELF 解释器 /lib/ld-linux-aarch64.so.1。
问题的根源在这里:
linux-arm64,.NET 会把目标环境当成标准 Linux,比如 Ubuntu、Debian、CentOS。/lib 目录和对应动态链接器,所以程序启动时会直接卡在地基上。所以,只靠 Termux 直接跑标准 Linux 的 .NET 原生程序并不稳。解决办法是加一层完整的 Ubuntu 用户态环境。
Termux 里可以通过 proot-distro 安装 Ubuntu。它不是虚拟机,也不是完整容器,但足够给我们提供一个接近标准 Linux 的用户态环境。
先安装 proot-distro:
pkg install proot-distro -y
然后安装 Ubuntu:
proot-distro install ubuntu
登录时建议把 Termux 的 home 目录挂载到 Ubuntu 的 /root,这样进去之后能直接看到前面解压好的程序:
proot-distro login ubuntu --bind ~:/root
成功后,提示符会变成类似 root@localhost:~#。这时候你已经进入 Ubuntu 环境了。
接下来补齐 .NET 常见依赖。极简 Ubuntu 里经常缺全球化、加密和压缩相关库:
apt update && apt install -y libicu-dev libssl-dev zlib1g libstdc++6
如果你的程序不是 AOT,或者没有通过 InvariantGlobalization=true 去掉 ICU 依赖,那么 libicu-dev 基本是必须的。否则程序可能报错,也可能没有明显输出就退出。

ICU 报错
虽然这里我们已经进入了 Ubuntu 子系统,并且补齐了依赖,但实际运行时,还是有坑要填的。
进入 Ubuntu 后,我以为事情差不多结束了。但实际运行时,.NET 程序仍然会出现“静默崩溃”:没有正常启动日志,没有明确错误,只留下一个退出状态。
这种时候不要靠猜,直接用 Linux 工具把它拆开看。
按下面四步排查,基本可以覆盖大多数启动失败场景。
Linux 程序退出后会留下状态码。先运行程序,再立刻查看 $?:
./bweb
echo $?
如果是 0,说明程序认为自己正常结束了;如果是 126、127、134、139 或 255,就要继续查权限、缺库、段错误或运行时崩溃。
先装 file,再看可执行文件的架构:
apt update && apt install file -y
file bweb
正常情况下,你应该能看到 aarch64、ARM64 或 64-bit 之类的信息。如果看到的是 armhf、32-bit,说明发布产物架构不对,先回到发布阶段重新打包。
用 ldd 看动态库依赖:
ldd bweb
如果输出里有 not found,就说明缺库。常见的是 libssl、libicu、zlib、libstdc++ 这一类,可以按前面的命令补齐。
如果前面几步都看不出问题,就上 strace:
apt install strace -y
strace ./bweb
strace 会输出大量系统调用,不需要每一行都看懂,重点看最后几行。通常会看到类似 SIGSEGV、ENOENT、EACCES、ENOMEM 这样的关键信息。

排查截图
这次 strace 把问题定位得很清楚,关键日志在最后几行:
mmap(NULL, 274877911040, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = -1 ENOMEM (Cannot allocate memory)
ENOMEM 表示系统拒绝了这次内存申请,程序随后以 255 退出。重点是这个数字:274877911040 字节,换算下来刚好是 256 GB,一上来就玩这么大么?
一个普通 .NET Web 程序刚启动,为什么要申请 256 GB?
这里和 .NET 的 Server GC 有关。通过 strace 日志可以看到,运行时识别到手机是 8 核处理器,比如读取到了类似 0-7 的 CPU 信息。Server GC 面向服务器吞吐场景,会按 CPU 核心规划堆空间。在这个环境里,它尝试为每个核心预留大约 32 GB 的虚拟地址空间,8 个核心就是 256 GB。
在常规 Linux 服务器上,这种大块虚拟内存预留通常能通过,因为系统允许内存超卖,也就是 Overcommit。它并不是马上吃掉 256 GB 物理内存,而是先占一块虚拟地址空间。
但现在环境比较特殊:程序跑在 Android 内核之上,再通过 Termux 和 proot 进入 Ubuntu 用户态。Android 对这种夸张的内存预留更敏感,内核直接拒绝了这次 mmap,于是 .NET 运行时刚启动就退出了。
既然根因是 Server GC 的虚拟内存预留,那解决就很直接:让 .NET 切回更适合小设备的 Workstation GC,当然这在其他平台上也是可以进行相关的优化。
在 Ubuntu 终端里执行:
export DOTNET_gcServer=0
这行命令会禁用 Server GC。为了更稳一点,还可以给 GC 堆加一个上限,比如限制到 512 MB:
export DOTNET_GCHeapHardLimit=536870912
然后重新启动程序:
./bweb
这次就应该能看到熟悉的 .NET 启动日志了。
如果希望每次进入 Ubuntu 后都自动生效,可以把这两行写进 ~/.bashrc:
echo 'export DOTNET_gcServer=0' >> ~/.bashrc
echo 'export DOTNET_GCHeapHardLimit=536870912' >> ~/.bashrc
source ~/.bashrc
最后整理一下完整启动步骤。进入 Ubuntu 后,先到程序目录,确认权限,再启动:
cd /root/bweb
chmod +x bweb
export DOTNET_gcServer=0
export DOTNET_GCHeapHardLimit=536870912
./bweb
另外,appsettings.json 里的监听端口建议设置为大于 1024。Android 底层不会允许普通应用随便监听 80、443 这类特权端口,换成 5000、8080、18080 这类端口会省很多事。
如果你希望局域网其他设备访问,还要确认 ASP.NET Core 不是只监听 localhost。可以在启动参数或环境变量里指定监听地址,例如:
export ASPNETCORE_URLS=http://0.0.0.0:8080
./bweb
这样电脑端就可以通过手机的局域网 IP 加端口访问这个 .NET Web 服务。
这次折腾看起来是“把 .NET Web 塞进手机”,实际踩到的是三层环境差异:
linux-arm64 程序需要 glibc 环境,直接在 Termux 里跑会缺 ELF 解释器。最终可行路径是:电脑端发布 linux-arm64,手机端用 Termux 安装 Ubuntu 子系统,在 Ubuntu 里补齐依赖,然后通过 DOTNET_gcServer=0 和 DOTNET_GCHeapHardLimit 控制 GC 行为。
它不一定适合长期生产部署,但用来做局域网小服务、临时演示、设备侧验证,废旧设备利用,还是挺有意思的。更重要的是,这个过程把 file、ldd、strace 这几个排查工具又串了一遍。遇到“没有输出就退出”的问题时,别急着重装环境,还是先让工具告诉你它到底死在哪里吧。
[1] Termux GitHub Releases: https://github.com/termux/termux-app/releases?wt.mc_id=DT-MVP-5005195