事情起因很简单:我不想每次调试都开电脑。

车在地板上、雷达在桌角、ESP32 插着充电宝——这套东西要动起来,传统上还需要一台笔记本当上位机, 跑 ROS 2、开 Agent、看着话题。而如果上位机能塞进口袋呢?需要的时候掏出来, 接个扩展坞,整套系统就活了。

所以我用一部旧手机试了。这篇记录的是它到底能不能干活,以及过程中撞上的三个坑。

01先搞清楚:这台"Ubuntu"到底是什么

第一次登录进去,neofetch 打出来的东西就有点不对劲:

终端 · neofetch
OS:        Ubuntu 26.04 LTS (Resolute Raccoon) aarch64
    Kernel:    Linux 5.15.197-gdf5e768acfe9-dirty
    Packages:  2117 (dpkg)
    Shell:     bash 5.3.9
    Terminal:  libproot.so
    CPU:       Cortex-A510+Cortex-A715+Cortex-A710+Cortex-X3 (1+4+3) @ 3.19 GHz
    GPU:       Qualcomm Turnip Adreno (TM) 740 [Integrated]
    Memory:    5.93 GiB / 10.94 GiB (54%)

里面有三条线索,拼起来指向同一个结论:

  • Terminal: libproot.so —— 这一行最直白。 正常的终端会显示 pts/0/dev/tty1, 这里显示的是一个动态库的名字。意思是:我没跑在真终端上,而是跑在一个用户态模拟层里。 PRoot 就是干这个的——它把系统调用翻译一遍,让一个 ARM64 的 Linux rootfs 以为自己在一台独立的机器上,实际上内核是共用的。
  • Kernel: 5.15.197-gdf5e768acfe9-dirty —— 这个内核版本号对不上。 Ubuntu 26.04 应该配的是 6.x 甚至更新的内核,而 5.15 这个分支是好几年前的 LTS。 原因是:这个内核不是 Ubuntu 的,是宿主机(Android)的内核被直接借用了。 PRoot 不提供内核,它只能复用宿主的内核。
  • CPU 那一行 —— Cortex-A510 + A715 + A710 + X3 (1+4+3), 配合 Adreno 740,这是手机 SoC 的典型构成 (一颗超大核 + 四颗大核 + 三颗小核),而不是任何 x86 笔记本的形态。 10.94 GiB 内存也像是 12 GB 手机的可用部分。

所以结论很明确:这是一台 Android 手机,里面用 PRoot 跑着一个 Ubuntu rootfs。 它不是虚拟机,没有独立内核;它也不是 Docker,没有命名空间隔离。 它就是「换了个根目录的一堆进程」。

这三个线索的排法值得记住:先看终端类型,再看内核版本,最后看 CPU 布局。 顺序不重要,重要的是——只要其中一条对不上"正常电脑"的样子,你就该怀疑自己在容器里。

02装:aarch64 上装 ROS 2

ROS 2 这部分我用了「一键安装」脚本(小鱼的那个),在手机上跑 wget 下载再执行:

安装 ROS 2
wget http://fishros.com/install -O fishros && . fishros

aarch64 平台的包是齐的,装完 ros2 --help 能正常输出,说明核心组件都到位了。 这一点比预想中顺利——毕竟这几年 ARM64 的服务器和开发板已经很常见, 发行版维护者基本都会同步构建。

真正麻烦的是后面三步,它们都和「这个环境不是一台正常电脑」有关。

03坑一:发行版名字写错了一个字母

第一次 source
bash: /opt/ros/lirical/setup.bash: No such file or directory

我把发行版名字拼错了。ROS 2 的发行版是按字母顺序起的(首字母递增), 拼写一次错就会得到一个"文件不存在",而那个报错不会提醒你"名字可能是错的"—— 它只会说找不到这个路径,让你去怀疑是不是没装成功。

更快的方法是直接去看目录:

确认发行版
ls /opt/ros/

里面那个目录名就是你要 source 的东西。别背发行版名字,去看目录—— 这是我在这种事情上唯一稳定的做法。

04坑二:Agent 编译,和一次"看起来改了其实没改"

micro-ROS Agent 需要从源码编译(它不在 apt 里)。在 aarch64 上编译时撞了依赖冲突, 我最后记下的解决方案步骤是这样的:

记录为「强制使用内置 fmt」的那套步骤
cd ~/microros_ws
    rm -rf build install log
    rm -rf src/micro_ros_setup          # 连克隆下来的源码也删掉重新拉

    cd src
    git clone https://github.com/micro-ROS/micro_ros_setup.git

    cd ~/microros_ws
    rosdep update
    rosdep install --from-paths src --ignore-src -y
    colcon build
    source install/setup.bash

当时我把这套步骤记成了「强制使用内置 fmt」——因为报错指向的是 fmt 库的版本冲突。

但现在回头读这段记录,我发现一个矛盾:这套步骤里没有任何一处改到了 fmt 相关的配置。 真正做的事只有一件——buildinstalllog 和一个源码目录全部删掉,重新来一遍

所以我现在的判断是:那个报错来自上一次构建留下的残留, 而不是 fmt 本身需要"强制"。删干净之后重新构建,自然就好了。 把它记成"强制内置 fmt"容易让人以为是改了编译选项,其实改的是缓存。

由此有一条我一直在用的规则:遇到依赖冲突,先清干净再看一遍报错。 如果清干净之后报错消失了,那说明问题出在残留物上; 如果报错还在,你才拿到了一个"干净的复现步骤"。

05坑三:没有 systemd,SSH 得自己叫起来

PRoot 里没有 systemd(连 PID 1 都不是它),所以 sudo systemctl start ssh 是无效的。 直接叫二进制:

手动起 sshd
sudo apt update && sudo apt install openssh-server -y

    sudo mkdir -p /var/run/sshd
    sudo /usr/sbin/sshd -o Port=2222 -o UsePAM=no \
         -o PermitRootLogin=yes -o PasswordAuthentication=yes

    ps aux | grep sshd            # 确认起来了
    ip a                          # 查本机 IP

从电脑上连:

~/.ssh/config
Host android-ubuntu
        HostName 192.168.31.161
        User root
        Port 2222
        ForwardX11 yes
        ForwardX11Trusted yes

ForwardX11 那两行是为了传图形界面——手机上跑 RViz 不现实, 但可以通过 X11 转发把窗口丢到电脑屏幕上显示。 顺手把这段启动命令写进 ~/.bashrc,就变成"每次进去自动起 SSH"了。

06跑起来是什么样

Agent 启动,ESP32 连上,会话建立的日志和在任何一台电脑上跑没有任何区别:

Agent · 手机侧
info | UDPv4AgentLinux.cpp | init              | running...          | port: 8888
    info | Root.cpp            | create_client     | create              | client_key: 0x296A6185
    info | SessionManager.hpp  | establish_session | session established | address: 192.168.31.20:47138
    info | ProxyClient.cpp     | create_participant| participant created | participant_id: 0x000(1)
    info | ProxyClient.cpp     | create_topic      | topic created       | topic_id: 0x000(2)
    info | ProxyClient.cpp     | create_publisher  | publisher created   | publisher_id: 0x000(3)
    info | ProxyClient.cpp     | create_subscriber | subscriber created  | subscriber_id: 0x000(4)
    info | ProxyClient.cpp     | create_datareader | datareader created  | datareader_id: 0x000(6)

数据也是通的:

终端 · 手机侧
root@Ubuntu:~# ros2 topic echo /robot/ping
    data: ping from ESP32
    ---
    data: ping from ESP32
    ---
    root@Ubuntu:~# ros2 topic pub /robot/pong std_msgs/String "data: 'hello from PC'" --once
    publishing #1: std_msgs.msg.String(data='hello from PC')

但有一个细节值得单独说:ros2 topic list 的输出是这样的——

ros2 topic list
/parameter_events
    /rosout

ESP32 那边的话题一个都没出现,可上面 echo 明明收到了数据。 这不是故障:micro-ROS 的话题挂在 Agent 的会话里,而 topic list 依赖 DDS 的发现机制, 当某个话题只有订阅端、没有发布者时,被发现的时间会延迟,甚至暂时不列出。 用 ros2 topic pub 真的发一条,或者直接 echo,才是可靠的验证方式。

频率方面的实测:

ros2 topic hz /robot/ping
average rate: 0.533     min: 1.875s max: 1.875s std dev: 0.00000s window: 1
    average rate: 0.676     min: 1.085s max: 1.875s std dev: 0.39528s window: 2
    average rate: 0.756     min: 1.009s max: 1.875s std dev: 0.39180s window: 3
    average rate: 0.674     min: 1.009s max: 1.964s std dev: 0.43831s window: 4

大约 0.5 到 0.75 Hz,最慢的间隔接近 2 秒,标准差 0.4 秒左右。也就是说: 链路是通的,但节奏很鬆。对 ping/pong 测试无所谓, 对需要稳定周期的控制指令就不够了——这和我在 另一篇笔记里追过的局域网延迟是同一类问题。

07它的边界在哪

用了几天之后,我对这台"口袋上位机"的能力有了比较清楚的边界认知:

能做不能做
跑 Agent、收发话题、ros2 topic 全套命令 有线串口——写这段时我才确认,手机的 USB 口在未 root 的情况下拿不到 /dev/ttyUSB*
SSH 进去当一台无头服务器用 RViz 这类图形工具(可以 X11 转发到电脑,但那就不是"便携"了)
通过 X11 转发显示单个窗口 长时间高负载——手机的散热和电池都不是为这个设计的

有线串口这一条是最实际的限制。原因不在软件,在于 Android 对 USB 设备的权限控制: 没有 root,普通应用拿不到串口设备节点。绕不过去,所以最终只能走 WiFi—— 而 WiFi 的稳定性又是另一个话题了。

08所以,值不值

我的判断是:作为"随身应急上位机",值;作为主力开发机,不值。

值得的地方在于它解决了一个具体问题——当你想在客厅地板上调车、 又不想搬电脑的时候,掏出一部手机就够了。整套 ROS 2 环境它跑得动, Agent 能起来,话题能收发,这些已经覆盖了"看一眼数据"的需求。

不值的地方在于三个结构性限制:没有 systemd(所有服务都要手动管)、 没有 USB(只能无线)、没有图形界面(只能远程)。 这三条不是配置问题,是 PRoot 这个方案的固有边界。

写这段的时候我在便签里留了一句话,我觉得它比任何总结都准确:

「很明显我的上位机不是文档里所说的上位机。小问题,上位机能用就行了嘛。」

标准意义上的上位机是工控机或者笔记本:有串口、有系统服务、有屏幕。 这台手机三条都不满足。但它的确能收发 ROS 2 话题——那就够了。 关键是要知道自己在哪个边界之内工作,而不是假装边界不存在。

关于本文
整理自 2026 年 8 月 13 日创建、8 月 29 日更新的一段便签, 内含当时的终端输出与 Agent 日志原文。设备为一部 PRoot 环境下的 Android 手机。
2026.09.20 整理
重读时改了一处结论:把「强制使用内置 fmt」改成了「先清干净再构建」。 原始记录里的步骤和它自己的标题对不上,而真正起作用的显然是清理。