手上这块 rv1126 开发板,内存不大,我一开始图省事,直接在板子上把 CPython 源码拉下来编译,结果跑了一会儿就报virtual memory exhausted: Cannot allocate memory,甚至整个终端直接卡死,只能断电重启。标题里这个“内存不足报错”我前前后后踩过好几轮,这里把排查过程、解决思路和最终能稳定跑通的配置一起整理出来,给同样在低内存 ARM 板上编译 Python 的朋友做个参考。
这篇文章适合两类人:一类是刚拿到 rv1126 这类开发板,想从源码编译 Python 却总被内存不足卡住的新手;另一类是已经尝试过交叉编译,但中途遇到各种 configure、链接、导入问题的老手。我尽量把每一步的“为什么”也讲清楚,而不只是丢一串能跑的命令。
1. 为什么在 rv1126 开发板上编译 CPython 会撑爆内存
1.1 板子本身的硬件边界
rv1126 开发板的典型内存配置是 256MB 或 512MB,我这块是 256MB 版本。听起来 256MB 好像也不算少,但你要知道系统起来之后,内核、基础文件系统、后台服务已经吃掉了一大部分,真正留给编译进程的往往只有 100MB 出头。free -m看下来,available 常常只有 110MB 上下。
而编译 CPython 并不是那种“写写小 C 文件、几十秒搞定”的轻量编译。它涉及大量 C 源文件,还有解析器生成、语法表生成、扩展模块构建这几个阶段,其中最吃内存的是编译优化和生成目标文件的过程。GCC 在处理某些大文件时需要占用非常大的内存,比如Python/ast.c、Python/compile.c、Parser/tokenizer.c这几类文件,单个文件的编译峰值就能超过一两百 MB。256MB 板上物理内存一满,系统就会触发 OOM Killer,把cc1或者collect2杀掉,于是你看到的就是virtual memory exhausted,或者是没有任何明确原因的“Killed”。
还有一个经常被忽略的点:编译优化级别越高,内存峰值越大。默认 CFLAGS 里的-O3会让 GCC 做重度优化,包括内联、向量化、常量传播,这些优化在大文件上非常吃内存。很多嵌入式开发板自带的开源代码默认都是-O2或-Os,但 CPython 官方源码的默认配置并不会因为你是在小内存板子上编译就自动降低优化级别。
1.2 真正的瓶颈不只在 CPU,更在内存带宽和交换速度
有人会想:那我加一个 swap 分区,把内存不够的部分放到交换文件里,是不是就能编译了?思路没错,但需要提前做好心理准备。rv1126 的存储介质一般是 eMMC 或 SD 卡,随机读写速度和内存总线完全不是一个量级。当 GCC 的内存压力一大,系统会频繁把物理内存里的页换到 swap,又从 swap 读回来,这个过程会让编译速度呈指数级恶化。
我实测过一次:256MB 板上,加 1GB swap,然后make -j4,跑了一个小时后进度还停在某个大文件上,CPU 使用率却不低,因为大部分时间都耗在换页上了。你说它完全不能运行吗?不是。但要在这种状态下等它编完整个 CPython,时间成本高到离谱,而且 eMMC 的频繁写入还会加速存储损耗。
1.3 并行编译加剧了内存峰值
make默认不指定-j的时候,有些环境会默认使用 CPU 核心数。rv1126 是四核处理器,如果你直接make -j4,GCC 会同时起 4 个cc1进程。每个cc1吃 150MB,4 个就是 600MB,这在 256MB 板上必死无疑,根本不需要等到某个大文件才爆内存。
即便你降到make -j1,单进程编译大文件时仍然可能把剩余内存吃尽。也就是说,内存不足并不单是“并行任务太多”造成的,而是单文件 + 优化 + 剩余内存三者的总和问题。
2. 整体思路:本地硬扛还是交叉编译
2.1 先判断你真正需要什么
看到内存不足之后,第一件事不是急着调参数,而是想清楚:你是非要在开发板上编译出 Python,还是只要最终的 Python 能在这个板子上运行?
如果只是要在 rv1126 上运行 Python 脚本,那交叉编译是绝对正路。交叉编译的意思是,在 PC 上用交叉工具链编出 ARM 平台的可执行文件,然后把产物拷贝到开发板运行。编译过程完全使用 PC 的大内存,板子只负责运行时环境。这样就从根本上绕开了内存不足。
如果你一定要求在板上本地编译,比如你想在板子上反复测试不同 Python 版本,或者你的网络环境导致交叉编译工具链不好拿,那也不是完全没救,可以通过加 swap、降并行、裁剪模块来把内存需求压到板子能接受的范围。只是你要接受一个现实:编译一次可能要几个小时,而且中途断电或 OOM 就可能前功尽弃。
2.2 交叉编译 CPython 的难点不在“改配置”,而在构建流程
交叉编译 CPython 和交叉编译普通 C 项目有个明显区别:CPython 的构建过程里有一部分工具是需要先在本机上执行的。比如解析器生成器pgen,以及后续用来生成冻结模块代码的宿主 Python 解释器。这些工具必须是能在 PC 上运行的版本,不能是 ARM 版。
很多第一次做交叉编译的人会在这一步卡住:明明./configure已经指定了--host=arm-linux-gnueabihf,但 make 一跑,说python: cannot execute binary file。原因就是 make 尝试在 PC 上执行刚刚编出来的 ARM 版python。这个坑在 CPython 3.8 以上版本特别明显,所以交叉编译前一定要先准备一个能够在本机运行的“宿主版 Python”,并把它显式告诉给目标编译过程的 Makefile。
2.3 交叉编译的整体流程拆解
整个流程其实可以拆成几段:
- 在 PC 上准备交叉工具链,确认编译器能正常编出 ARM 可执行文件。
- 用 PC 自带的系统 Python 或单独编译一个 host Python,这个 Python 只用于构建过程,不进入最终产物。
- 配置 CPython 源码,目标平台指向 ARM,宿主平台指向 x86_64。
- 使用
HOSTPYTHON、HOSTPGEN等变量告诉 make 不要执行目标平台的二进制文件。 - 编译完成后,将目标平台相关的库、可执行文件、头文件打包拷贝到开发板。
这个流程的次数多了以后,你会发现交叉编译的报错信息往往比本地编译更抽象,因为它经常会混淆“编译目标代码”和“运行本机工具”两个阶段,所以我们后面要单独用一个章节讲排障。
3. 交叉编译 CPython 的具体配置过程
3.1 工具链选择和基础环境
不同开发板 SDK 带出来的交叉工具链可能不太一样,常见前缀有arm-linux-gnueabihf-,也有厂商定制版arm-rockchip-linux-gnueabihf-。不管哪种,只要编译出的程序能在目标板运行,都可用。
我通常先做一个最小测试,确认工具链可用,再开始配 Python。比如:
export CC=arm-linux-gnueabihf-gcc export CXX=arm-linux-gnueabihf-g++ export AR=arm-linux-gnueabihf-ar export RANLIB=arm-linux-gnueabihf-ranlib export LD=arm-linux-gnueabihf-ld export READELF=arm-linux-gnueabihf-readelf写一个hello.c然后用上面的$CC编译,再放到板子上跑一下。这一步能提前暴露工具链的 sysroot、动态链接器等基础问题,不用等到 Python 源码那边才炸。
接下来下载 CPython 源码包,我用的是 3.10.12,其他版本思路一致,只是个别配置项名可能不同。
3.2 先在 PC 上编译一个 host Python
交叉编译 CPython 时,Makefile 的某些阶段需要一个能在 PC 上执行的 Python。官方源码包里自带的configure脚本本身也能在 PC 上构建,所以我们先编一个不带优化、只供构建使用的版本。
这是我常用的方式:
tar xf Python-3.10.12.tgz cd Python-3.10.12 mkdir build-host cd build-host ../configure --prefix=$(pwd)/install make -j$(nproc) make install cd ..这一步编译出来的build-host/install/bin/python3,就是后续交叉编译时要传给HOSTPYTHON的解释器。
有一个容易犯的错:不要把这个 host Python 直接当成最终产物给开发板用。它是 x86_64 指令集的,板子跑不了。它只服务于构建期脚本,比如解析生成、字节码表生成、冻结模块生成。
3.3 交叉编译的 configure 参数
在源码根目录下新建一个build-target目录,把目标平台的配置放进去:
mkdir build-target cd build-target ../configure \ --host=arm-linux-gnueabihf \ --build=x86_64-linux-gnu \ --prefix=/usr/local/python3 \ --without-ensurepip \ --disable-shared \ ac_cv_file__dev_ptmx=no \ ac_cv_file__dev_ptc=no \ ac_cv_have_long_long=yes简单解释几个关键选项:
--host=arm-linux-gnueabihf告诉 configure 最后生成的目标平台是 ARM。--build=x86_64-linux-gnu告诉 configure 当前构建环境是 PC。--prefix=/usr/local/python3是最终安装到开发板上的路径,建议一开始就统一好,免得后面 PATH 混乱。--without-ensurepip可以避免构建过程中尝试把 pip 相关的内容也捆绑进去,减少编译负担。--disable-shared用来生成静态链接的python可执行文件。这个要根据实际场景取舍,静态链接部署简单,但体积大;动态链接可以共用库,但需要你额外处理.so文件。ac_cv_file__dev_ptmx=no和ac_cv_file__dev_ptc=no是经典的交叉编译环境变量,用来绕过 configure 在交叉环境下对/dev/ptmx这类设备文件的错误判断。如果不加,configure 有可能得到错误的检测结果,导致生成的配置里缺少某些功能。
configure 跑完后,检查一下输出末尾是否成功生成Makefile,以及关键变量是否正确。
3.4 编译目标平台的 Python
configure 完成之后,不能直接无脑make。要先把 host Python 相关路径传进去:
make -j4 \ HOSTPYTHON=../build-host/install/bin/python3 \ HOSTPGEN=../build-host/install/bin/pgen3这里HOSTPYTHON指定 PC 用的解释器,HOSTPGEN指定 PC 用的解析器生成器。不同 Python 版本里,pgen的路径可能不太一样,有的叫Parser/pgen,有的直接就是pgen3。如果你在 make 阶段接到类似No rule to make target '../build-host/install/bin/pgen3'的报错,就去build-host目录下找一下实际文件名。
make 结束后,产物会出现在build-target目录里。你大概注意一下几个关键文件:
python或python3可执行文件libpython3.10.a或libpython3.10.soModules目录下的扩展模块.so文件
然后执行:
make install DESTDIR=${PWD}/target-root这样会把所有文件安装到target-root/usr/local/python3下,整体打包后拷贝到开发板对应路径即可。注意是把target-root里的内容合入开发板根目录,而不是把target-root整个目录当根文件系统铺上去。
提示:交叉编译时如果还缺
libffi、zlib这类依赖,通常要先交叉编译这些第三方库,或者在 configure 阶段显式指定它们的头文件和库文件路径。最常见的现象是编译期通过,但运行期import _ctypes失败。
4. 如果必须本地硬编译,怎么把内存需求压下来
4.1 先创建足够大的 swap
如果你因为各种原因不想交叉编译,那第一步是把物理内存不够的部分用 swap 补齐。创建 swap 的方法很固定:
free -m dd if=/dev/zero of=/swapfile bs=1M count=1024 chmod 600 /swapfile mkswap /swapfile swapon /swapfile我一般先用free -m看一眼当前可用内存,再估算 swap 大小。256MB 的板子,至少加 1GB swap 才有一点安全感;如果你的板载存储还有余量,加到 2GB 也可以。但说实话,swap 只能保证“不被立刻杀进程”,编译速度仍然会因为换页大幅度下降。
如果你用的是带压缩的 zram 或 zswap,效果会好一些,因为压缩后的页面在内存里占用更小。但不是每个开发板的 kernel 都默认支持,需要确认内核配置里有没有CONFIG_ZRAM。我一般不在基础问题上折腾这个,优先还是减少编译进程数。
4.2 用单线程编译并降低优化级别
本地硬编译时,不要用make -j4,老老实实从make -j1开始。如果单线程也爆,就说明内存峰值已经超出了可用范围。
可以尝试把优化级别从默认的-O3降下来。具体做法不是直接覆盖 CFLAGS,而是在 make 阶段追加:
make -j1 CFLAGS="-Os -pipe"但这里有个隐藏问题:直接覆盖 CFLAGS,可能会丢掉 Python 源码里为特定平台准备的一些默认宏定义。更稳妥的做法是用CFLAGS_NODIST这个变量,它专门用于“传给编译器但不影响 distutils 扩展模块编译”的选项:
make -j1 CFLAGS_NODIST="-Os -pipe"-pipe让 GCC 用管道而不是临时文件来传递中间结果,可以减少磁盘 I/O,但它不会减少内存占用,甚至可能稍微增加一点。所以如果你已经是因为内存不够才看这篇文章,-pipe加不加都行,主要靠-Os降低峰值。
另外也要把调试信息去掉,-g会让目标文件和中间符号非常占内存:
make -j1 CFLAGS_NODIST="-Os -g0 -pipe"4.3 通过裁剪模块减少编译总量
除了优化级别,模块裁剪也能直接降低编译压力。CPython 里的标准库和扩展模块非常多,但并不是每个嵌入式场景都用得上。
在源码目录中,Modules/Setup文件里有许多模块开关。比如你不需要_tkinter、_curses、_sqlite3,就保持注释状态,不要启用。你有没有注意到,configure 阶段即使不指定,很多模块也是默认开启的?如果你要让最终二进制尽量小,可以考虑在 configure 时加上:
--disable-ipv6 --without-readline --without-doc-strings不过这类选项对编译器内存峰值的改善没有想象中那么大。为什么?因为真正吃内存的是 Python 的核心大文件,这些是无论如何都会编译的。裁剪模块更多是减少“编译次数”和“最终体积”,而不是降低单次编译的内存峰值。
比较实际的做法是:先把最耗时的核心编译扛过去,如果中途某个模块编译失败,再在Modules/Setup里把它注释掉。不要一上来就大砍特砍,免得把运行时依赖的模块也裁掉。
4.4 把临时目录放到磁盘而不是 tmpfs
系统默认TMPDIR如果是内存盘,比如/dev/shm,那编译过程中 GCC 创建的临时文件也会占内存。小内存开发板最好把TMPDIR指向 eMMC 或 SD 卡路径:
export TMPDIR=/tmp这里的/tmp如果也是 tmpfs 挂在内存里,就要重新挂载到磁盘分区,或者换一个路径。检查方法很简单:
df -h /tmp看文件系统类型是不是tmpfs,如果是,而你又不想改挂载,那就:
mkdir -p /var/tmp/compile export TMPDIR=/var/tmp/compile注意,临时文件放磁盘虽然能释放内存压力,但会让编译更慢。这就是本地硬编译的必然取舍。
5. 实战排障:从编译期到运行期的高频问题
5.1 报错cc1: out of memory或virtual memory exhausted
这个错误是标题里那个问题的直接表现。它说明 GCC 在编译某个 C 源文件时申请不到足够内存。第一步先用free -m确认当前内存状态,如果available已经为 0,说明物理内存耗尽,swap 可能也没加或者加得不够。
处理优先级如下:
- 检查 swap 是否开启:
swapon -s。 - 用
make -j1消除并行叠加。 - 如果还不行,尝试
-Os和去掉-g。 - 再不行就放弃本地编译,转交叉编译。
千万别在 OOM 之后反复重启继续硬试,那是折磨自己。我印象最深的一次,以为某个版本源码有问题,反复 make clean、重试,最后发现是 swap 空间被日志文件占满了。
5.2 交叉编译时报checking for python3... no或python: cannot execute binary file
这个问题通常出现在你提前编译好目标平台产物,然后 configure 又试图用这个 ARM 二进制去做检查的时候。解决办法就是严格区分 host 与 target 两个目录,并确保 make 阶段把HOSTPYTHON指到 PC 版解释器。
还有一种情况是你在 configure 的时候不小心把PATH里的python指向了刚生成的 ARM 版,导致 configure 脚本直接执行失败。建议在交叉编译的终端里先确认当前python3是 PC 的:
python3 -V然后再进入build-target。
5.3 编译成功但目标板import扩展模块失败
这个非常隐蔽。交叉编译的时候,CPython 的sysconfig会记录编译时的平台信息。如果你在 x86 上交叉编译,但某些扩展模块没有被正确编译进 ARM 版本,就会出现“Python 本身可以运行,但import _ctypes、import sqlite3却报错”的情况。
以_ctypes为例,它依赖libffi,而libffi本身也有交叉编译版本。如果你没有提前把libffi的头文件路径传给 configure,那么_ctypes很可能会被静默跳过。排查时先看编译日志里有没有类似_ctypes的 build 记录,如果没有,说明它在 configure 阶段就被判定为不可用。
解决方法是先交叉编译libffi,然后通过CPPFLAGS和LDFLAGS告诉 CPython:
export CPPFLAGS="-I/path/to/arm-libffi/include" export LDFLAGS="-L/path/to/arm-libffi/lib"5.4 快速排障速查表
| 报错或现象 | 常见原因 | 处理方案 |
|---|---|---|
cc1: out of memory | 物理内存不足或并行任务过多 | 加 swap、降低并行数、改-Os、交叉编译 |
Killed且无明确日志 | OOM Killer 杀进程 | 检查dmesg,加 swap,减小内存峰值 |
python: cannot execute binary file | 交叉编译产物被拿到 PC 执行 | 设置HOSTPYTHON指向 PC 版解释器 |
checking for python3... no | configure 路径错误 | 确保 PC 版 Python 在 PATH 中 |
/usr/bin/ld: cannot find -lz | 缺少交叉编译版 zlib | 先交叉编译依赖库,指定LDFLAGS |
import _ctypes失败 | libffi 未正确交叉编译 | 编译 libffi 并传入CPPFLAGS/LDFLAGS |
No module named pip | 使用了--without-ensurepip | 需要时用get-pip.py或单独交叉编译 pip |
| 编译速度极慢 | 频繁 swap 换页 | 减小并行度、降低优化级别或转交叉编译 |
这张表基本覆盖了我遇到过得高频问题。如果你在编译新版本 CPython 时发现某个报错不在表里,优先去config.log里看最后一段完整错误,那往往比网上零散的经验贴更准确。
5.5 运行时内存不足也要提前测
编译期的问题解决了,不代表运行期没问题。CPython 跑在 256MB 板上,本身内存占用也不小。你写 Python 脚本时,如果一次性加载大量数据,或者递归调用很深,可能同样触发内存不足。建议在部署到开发板之前,先在 PC 上用同样的 Python 版本做一次内存基线测试。
有个环境变量可以帮你观察 CPython 的内存分配:PYTHONMALLOC=malloc或PYTHONMALLOC=debug。前者是让 Python 直接使用系统的 malloc,不启用自己的 pymalloc 分配器;后者会输出更详细的内存调试信息。不过运行时内存优化是另一个话题,这里不展开。
6. 我的最终建议和一个小习惯
现在我在手头这块 rv1126 开发板上做 Python 环境,第一选择永远是先看 SDK 里有没有现成的 Python 包。很多厂商的 buildroot 或 debian 镜像里其实已经带了可用的 Python,哪怕版本旧一点,也比我从源码编译省太多时间。如果必须用新版本,那就直接交叉编译,不要在板子上硬刚内存。
最后再分享一个我踩过几次坑之后养成的小习惯:编译前先把free -m、swapon -s和df -h /tmp三行命令的输出存到一个文本里,后面遇到诡异错误时翻一翻,能快速排除掉最基础的内存和临时目录问题。如果你第一次看到这个标题,我猜你现在也正盯着一个被 OOM 干掉的终端窗口,没关系,按上面方案换成交叉编译,你会回来谢我的。