1. 为什么要在 Windows 上折腾 arm64 的 deb 包
先把场景说清楚。你手头大概率是这么个情况:开发机是 Windows,日常写代码、调试都在上面;目标设备是一块 arm64 的开发板或者国产化整机,跑的是 Debian 系或者麒麟这类系统,装软件靠dpkg -i。你不想为了打个包专门再开一台 Linux 物理机,也不想在板子上装一堆编译工具链(板子性能弱、存储小、编译一次等半天),于是最省事的路径就是:在 Windows 上通过 WSL 跑一个 Linux 环境,用交叉编译工具链把 arm64 的二进制编出来,再手工组装成 deb 包,最后拷到板子上dpkg -i一行装完。
这条路我走过很多次,从早期的树莓派到后来的 RK3568 这类板子,套路基本一致。核心关键词就五个:arm64、deb、WSL、交叉编译、dpkg。把这五个词串起来,就是一条完整的交付链路。
先说清楚几个容易混淆的概念,不然后面全是坑。
arm64 和 x64 到底差在哪。最直观的区别是指令集架构不同。x64 是复杂指令集(CISC)体系,arm64 是精简指令集(RISC)体系,两者的机器码完全不通用。你在 Windows 上gcc编出来的可执行文件,扔到 arm64 板子上根本跑不起来,会直接报cannot execute binary file: Exec format error。所以必须用交叉编译——在 x64 主机上运行一个能生成 arm64 机器码的编译器。这个编译器本身是 x64 程序,但它吐出来的产物是 arm64 的,这就是"交叉"二字的含义。
deb 包不只是个压缩包。很多人以为 deb 就是tar打包改个后缀,其实它内部有严格结构:DEBIAN/control描述元信息,DEBIAN/postinst、prerm等是安装前后执行的脚本,其余目录才是实际文件。dpkg -i会读 control 做依赖检查,跑脚本,再把文件铺到对应路径。结构错了,dpkg直接拒绝安装。
WSL 在这里扮演什么角色。WSL(Windows Subsystem for Linux)让你在 Windows 里直接跑一个完整的 Linux 用户态,文件系统互通、命令行原生。对交叉编译来说,它最大的价值是:Linux 下的交叉编译工具链生态最完整,各种aarch64-linux-gnu-前缀的工具、dpkg-deb、fakeroot都是 Linux 原生工具,在 WSL 里用起来和真机没区别。你不需要双系统,也不需要虚拟机来回切。
谁适合看这篇。如果你是把软件交付到 arm64 板子上的嵌入式/后端/运维同学,或者在做国产化适配需要出 deb 包,或者单纯想搞清楚"交叉编译 + 打包"这条链路,这篇都能直接抄。前提是你对 Linux 命令行不陌生,知道make、gcc大概是怎么回事。
下面我按真实操作顺序拆:先搭 WSL 环境,再装交叉工具链,然后讲编译和打包,最后是板子上安装和排错。每一步我都会说清楚"为什么这么做",而不是只给命令。
2. WSL 环境搭建与交叉工具链选型
2.1 WSL 安装:别踩网络和虚拟化的坑
WSL 的安装现在一条命令就能搞定,但实际执行时翻车率不低,主要集中在两个地方:虚拟化没开和下载太慢被重置。
先确认虚拟化。任务管理器 → 性能 → CPU,看右下角"虚拟化"是不是"已启用"。如果是"已禁用",得进 BIOS 打开 VT-x / AMD-V。没开的话,wsl --install到最后会甩给你一个有关信息,请访问 https://aka.ms/enablevirtualization的错误,很多人卡在这以为是 WSL 坏了,其实是硬件虚拟化没开。
安装命令本身:
wsl --install -d Ubuntu-d Ubuntu指定发行版,不指定的话默认也是 Ubuntu。如果你遇到wsl --install 太慢、网速慢被重置这类情况,可以加--web-download走另一条下载通道:
wsl --install -d Ubuntu --web-download实测下来--web-download在某些网络环境下确实更稳,因为它绕开了 Microsoft Store 的分发链路。如果还是慢,那就只能手动下载发行版离线包再wsl --import导入,这是最兜底的办法。
注意:如果你之前装过 WSL 又卸载过,可能会遇到
wsl/installdistro/service/registerdistro/createvm/hcs/error_file_not_found或者"组件存储已损坏"。这种一般是 WSL 相关组件残留导致的,先在"启用或关闭 Windows 功能"里把"适用于 Linux 的 Windows 子系统"和"虚拟机平台"两个勾去掉,重启,再重新勾上,重启,然后再装。别嫌麻烦,这一步能省掉后面一堆玄学问题。
还有个常见需求是把 WSL 装到 D 盘。默认 WSL 装在 C 盘,Ubuntu 加上后面装的工具链、源码,几十个 G 很容易把系统盘撑爆。做法是先导出再导入到目标盘:
wsl --export Ubuntu D:\wsl\ubuntu.tar wsl --unregister Ubuntu wsl --import Ubuntu D:\wsl\Ubuntu D:\wsl\ubuntu.tar --version 2导入后默认用户会变成 root,需要改回普通用户,编辑/etc/wsl.conf加一段:
[user] default=你的用户名然后wsl --shutdown重启生效。这一步不做的话,后面所有操作都是 root 身份,编译出来的文件属主全是 root,打包时权限会乱。
2.2 交叉编译工具链怎么选
工具链是整条链路的核心。arm64 的交叉编译工具链有好几个来源,选错了要么编不过,要么编出来的东西在板子上跑不起来。
| 工具链来源 | 前缀示例 | 适用场景 | 备注 |
|---|---|---|---|
| Ubuntu 官方仓库 | aarch64-linux-gnu- | 通用 Debian/Ubuntu arm64 | 最省事,apt直接装 |
| Linaro | aarch64-linux-gnu- | 通用,版本较新 | 需手动下载配置 PATH |
| 芯片厂商 SDK | aarch64-none-linux-gnu- | 特定板子(如 RK3568) | 与板子系统库匹配最好 |
| Buildroot/Yocto 产物 | 视配置而定 | 定制系统 | 与目标 rootfs 完全一致 |
怎么选?我的经验是:如果板子跑的是标准 Debian/Ubuntu/麒麟,优先用 Ubuntu 仓库里的gcc-aarch64-linux-gnu,一条命令搞定,省心。如果是野火 RK3568 这类厂商板子,厂商通常会提供配套的交叉编译工具链(搜"野火 rk3568 交叉编译工具链下载"能找到),优先用厂商的,因为它的 glibc 版本、系统库和板子上的 rootfs 是对齐的,能避免"编译时链接的库版本比板子上新"这种经典问题。
Ubuntu 仓库安装:
sudo apt update sudo apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu装完验证:
aarch64-linux-gnu-gcc --version能打印版本号就说明工具链就位。这个包同时会带上aarch64-linux-gnu-ld、aarch64-linux-gnu-ar等一整套 binutils。
为什么强调工具链要和目标系统匹配?交叉编译出来的二进制,动态链接的是目标系统的libc。如果你用 glibc 2.35 的工具链编译,板子上只有 glibc 2.31,运行时会报version 'GLIBC_2.35' not found。这不是编译错误,是运行时才炸,特别隐蔽。所以要么用厂商工具链,要么在编译时静态链接(加-static),要么在板子上确认 glibc 版本后选对应工具链。
2.3 补齐打包工具
交叉编译只产出二进制,打包还得靠dpkg-deb和fakeroot:
sudo apt install -y dpkg-dev fakerootdpkg-deb负责把目录结构压成 deb,fakeroot让你在不真正拥有 root 权限的情况下,模拟出 root 属主的文件权限——因为 deb 包里很多文件属主必须是 root,但你不想真的用 root 去操作。这俩是打包的标配,缺一不可。
3. 从源码到 arm64 二进制的交叉编译实操
3.1 一个最小可复现的例子
为了把流程讲透,我用一个最简单的 C 程序当例子,你把它换成自己的项目即可。
// hello.c #include <stdio.h> int main(void) { printf("hello from arm64\n"); return 0; }交叉编译:
aarch64-linux-gnu-gcc hello.c -o hello验证产物架构:
file hello输出应该是ELF 64-bit LSB executable, ARM aarch64。如果显示的是x86-64,说明你用的是本机 gcc 而不是交叉编译器,检查命令前缀。
这里有个高频坑:很多项目的构建系统(Makefile、CMake)默认用gcc,你直接make出来的还是 x64。正确做法是通过环境变量覆盖:
export CC=aarch64-linux-gnu-gcc export CXX=aarch64-linux-gnu-g++ export AR=aarch64-linux-gnu-ar export STRIP=aarch64-linux-gnu-strip然后make才会用交叉编译器。CMake 的话用 toolchain file 更规范:
# aarch64.cmake set(CMAKE_SYSTEM_NAME Linux) set(CMAKE_SYSTEM_PROCESSOR aarch64) set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc) set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++) set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)配置时cmake -DCMAKE_TOOLCHAIN_FILE=aarch64.cmake ..。FIND_ROOT_PATH_MODE那三行很关键,它告诉 CMake 找库和头文件时只在目标系统路径里找,别把主机的 x64 库混进来,否则链接阶段会报一堆架构不匹配。
3.2 依赖库的交叉编译顺序
真实项目很少是单文件,通常依赖第三方库。这时候有个铁律:依赖库必须也用 arm64 工具链编,且编出来的.so/.a要放在交叉编译器能找到的路径下。
假设你的程序依赖libfoo,流程是:
- 下载 libfoo 源码。
- 用
aarch64-linux-gnu-gcc编译,--prefix指向一个专门的 sysroot 目录,比如/opt/arm64-sysroot。 - 编译主程序时,通过
-I/opt/arm64-sysroot/include和-L/opt/arm64-sysroot/lib指定头文件和库路径。
# 编 libfoo ./configure --host=aarch64-linux-gnu --prefix=/opt/arm64-sysroot make && make install # 编主程序 aarch64-linux-gnu-gcc main.c -I/opt/arm64-sysroot/include \ -L/opt/arm64-sysroot/lib -lfoo -o main--host=aarch64-linux-gnu是 autotools 项目的交叉编译开关,它会自动去找aarch64-linux-gnu-gcc。这一步漏了,configure 会按主机架构编,后面链接必炸。
踩过的坑:有些库的 configure 脚本会去探测主机能力,比如检测某个函数是否存在,探测时用的是主机编译器,结果得出错误结论。这时候要么用--cache-file预置探测结果,要么直接改 configure 脚本。这类问题没有通用解,只能具体库具体分析,但知道"探测阶段可能被主机污染"这个思路,排查起来就有方向了。
3.3 静态链接还是动态链接
这是个必须提前决定的问题,因为它直接影响 deb 包的内容。
动态链接:二进制小,但依赖目标系统的.so。如果板子上缺库,运行时报error while loading shared libraries。deb 的control文件里可以用Depends声明依赖,让dpkg帮你检查。
静态链接:二进制大(可能几十 MB),但把库都塞进去了,板子上只要有基本系统就能跑,不挑库版本。加-static即可。
我的建议:如果依赖简单、板子系统标准,用动态链接 + Depends 声明,包小、干净。如果依赖复杂、板子系统版本不确定,或者你懒得管依赖,直接静态链接,一劳永逸。嵌入式交付里静态链接用得非常多,就是图个省心。
注意:glibc 的静态链接在某些涉及 NSS(名称解析、用户查询)的场景会有警告,如果程序用到
getaddrinfo之类,静态链接可能不完全工作。这种场景要么用 musl 静态链接,要么老老实实动态链接。
4. 手工组装一个规范的 deb 包
4.1 deb 包的目录结构
dpkg-deb打包时,你的工作目录必须长这样:
myapp/ ├── DEBIAN/ │ ├── control │ ├── postinst (可选) │ ├── prerm (可选) │ └── conffiles (可选) └── usr/ ├── bin/ │ └── myapp └── lib/ └── libfoo.soDEBIAN/目录是元信息,不会被安装到系统里;其余目录按原样铺到根文件系统。也就是说usr/bin/myapp最终会装到/usr/bin/myapp。
4.2 control 文件怎么写
control是 deb 的灵魂,格式是字段: 值,字段之间空行分隔。一个最小可用的例子:
Package: myapp Version: 1.0.0 Architecture: arm64 Maintainer: yourname <you@example.com> Depends: libc6 (>= 2.31) Section: utils Priority: optional Description: A demo app for arm64 This is a longer description. Multi-line uses a leading space.几个字段必须说清楚:
- Package:包名,只能小写字母、数字、
-、+、.,不能有大写。 - Version:版本号,
dpkg用它判断升级。建议用主版本.次版本.修订号格式。 - Architecture:这里必须写
arm64。写错了dpkg会拒绝安装,报package architecture (xxx) does not match system (arm64)。这是新手最常犯的错。 - Depends:依赖声明。
libc6 (>= 2.31)表示需要 glibc 2.31 以上。板子上不满足时dpkg -i会报依赖错误。 - Description:第一行是简短描述,后续行以空格开头是详细描述。
Architecture 字段的坑:如果你在 x64 的 WSL 里打包,dpkg-deb默认可能按主机架构填,所以一定要手动写死arm64,别指望它自动识别。
4.3 打包命令与权限处理
组装好目录后,打包:
fakeroot dpkg-deb --build myapp myapp_1.0.0_arm64.deb--build后面跟源目录和目标文件名。文件名建议遵循包名_版本_架构.deb的约定,虽然dpkg不强制,但这是社区惯例,方便识别。
为什么用fakeroot?因为 deb 包里/usr/bin/myapp这类文件的属主应该是root:root,权限比如755。但你当前是普通用户,直接chown root会失败。fakeroot拦截这些系统调用,让dpkg-deb以为文件属主是 root,实际打包时记录的就是 root。不加fakeroot的话,装到板子上文件属主会变成你的用户名,某些程序会因此拒绝运行。
验证包内容:
dpkg-deb -c myapp_1.0.0_arm64.deb # 列出文件 dpkg-deb -I myapp_1.0.0_arm64.deb # 查看 control 信息-c看文件列表和权限,-I看元信息。打包后养成习惯先看一眼,确认架构是 arm64、文件路径对、权限对,再往板子上拷。
4.4 postinst 脚本:安装后自动干活
很多场景下,装完包还需要做点事,比如创建用户、启动服务、更新缓存。这些放在DEBIAN/postinst里,dpkg安装完会自动执行。
#!/bin/sh set -e # 创建运行用户 if ! id myapp >/dev/null 2>&1; then useradd -r -s /usr/sbin/nologin myapp fi # 重载 systemd systemctl daemon-reload || true exit 0几个要点:
- 必须有 shebang(
#!/bin/sh),且脚本要有可执行权限(chmod 755 postinst)。 set -e让脚本遇错即停,避免半途出错还继续。exit 0结尾,返回非零dpkg会认为安装失败。- 脚本里调用
systemctl时加|| true,因为有些环境(比如容器)没有 systemd,不加会导致安装失败。
对应的prerm(卸载前执行)和postrm(卸载后执行)同理。脚本里不要做耗时操作,dpkg安装是同步的,脚本卡住整个安装就卡住。
5. 板子上的安装、验证与排错
5.1 传输与安装
把 deb 拷到板子上,方式随意:scp、U 盘、adb push都行。假设拷到了/tmp:
sudo dpkg -i /tmp/myapp_1.0.0_arm64.deb如果依赖满足,直接装完。如果报依赖缺失:
sudo apt-get install -f-f会尝试自动修复依赖。但注意,板子不一定能联网,离线环境下apt-get install -f也没用,只能手动把依赖的 deb 也拷过去一起装。
5.2 验证安装结果
dpkg -l | grep myapp # 确认已安装 dpkg -L myapp # 列出包安装的所有文件 /usr/bin/myapp # 直接运行dpkg -L特别有用,能确认文件都铺到了预期位置。如果运行报No such file or directory但你明明看到文件在,八成是动态链接器路径不对——比如二进制期望的 interpreter 是/lib/ld-linux-aarch64.so.1,但板子上路径不同。用readelf -l /usr/bin/myapp | grep interpreter看期望路径,再对比板子实际路径。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查/解决 |
|---|---|---|
Exec format error | 装的是 x64 二进制 | file确认架构,重新交叉编译 |
package architecture does not match | control 里 Architecture 写错 | 改成arm64重新打包 |
version 'GLIBC_2.xx' not found | 工具链 glibc 比板子新 | 换匹配的工具链或静态链接 |
error while loading shared libraries | 缺依赖库 | ldd查依赖,补库或声明 Depends |
| 文件属主不是 root | 打包时没用 fakeroot | 加fakeroot重新打包 |
| postinst 没执行 | 脚本无执行权限或无 shebang | chmod 755,补 shebang |
dpkg报依赖错误且无法联网 | 离线环境缺依赖 | 手动拷依赖 deb 一起装 |
5.4 几个我踩过的坑
坑一:strip 掉了符号导致崩溃难查。发布包通常会strip减小体积,但一旦线上崩溃,没有符号表根本没法定位。我的做法是:发布包 strip,但保留一份带符号的二进制单独存档,版本号对应好。出问题时用带符号的版本配合addr2line定位。
坑二:时间戳导致重复打包内容不一致。同样的源码,两次打包出来的 deb 校验和可能不同,因为文件 mtime 不一样。如果要做可复现构建,打包前统一时间戳:
find myapp -exec touch -d "2024-01-01 00:00:00" {} \;这样每次打包结果一致,方便做校验和比对。
坑三:路径里带了主机绝对路径。有些构建系统会把编译时的绝对路径写进二进制(比如调试信息、rpath)。板子上没这个路径,运行时可能去找不存在的库。用readelf -d看RPATH/RUNPATH,有主机路径的话要么去掉,要么改成相对路径。
坑四:WSL 文件系统性能。如果你把源码放在/mnt/c/...(也就是 Windows 盘)下编译,IO 会非常慢,因为跨了文件系统边界。把源码放在 WSL 自己的文件系统里(比如~/project),编译速度能快好几倍。这是 WSL 使用的一个通用经验,交叉编译这种大量读写文件的场景尤其明显。
6. 关于 qemu 模拟验证的一点补充
有时候板子不在手边,你想先验证 arm64 二进制能不能跑。这时候可以用 qemu 用户态模拟:
sudo apt install -y qemu-user-static qemu-aarch64-static ./hello如果程序是动态链接的,还得指定 sysroot:
qemu-aarch64-static -L /usr/aarch64-linux-gnu ./hello-L指向 arm64 的库目录。这样能在 x64 主机上直接跑 arm64 程序,快速验证逻辑。但要注意,qemu 模拟和真机行为不完全一致,尤其是涉及硬件、内核特性、性能相关的部分。它适合做功能冒烟测试,不能替代真机验证。
更进一步,可以用qemu-debootstrap搭一个完整的 arm64 chroot 环境,在里面dpkg -i测试安装流程,连 postinst 脚本都能跑。这对没有板子时的验证非常有用。不过 chroot 环境搭建有点繁琐,属于进阶操作,日常交付还是建议以真机为准。
7. 把整条链路串起来
回到最初的问题:Windows 上打 arm64 的 deb 包,本质是把"编译"和"打包"两件事在 WSL 里完成,产物直接扔给板子。整条链路的顺序是:
- WSL 装好 Ubuntu,确认虚拟化开启、装在合适的盘。
- 装交叉编译工具链(优先厂商的,其次 Ubuntu 仓库的)和打包工具。
- 用
aarch64-linux-gnu-前缀的工具编译源码,依赖库也要交叉编译。 - 按 deb 目录结构组装,写好 control 和脚本,用
fakeroot dpkg-deb --build打包。 - 拷到板子
dpkg -i,验证运行,出问题按速查表排查。
这套流程我用了很多年,从简单的单文件工具到带 systemd 服务的复杂应用都跑通过。真正花时间的从来不是命令本身,而是工具链和板子系统的匹配、依赖库的交叉编译、打包时的权限和架构字段这几个点。把这几处搞明白,剩下的就是体力活。
最后分享一个提高效率的小习惯:把打包过程写成一个build.sh,把工具链前缀、版本号、目标架构都做成变量。每次发版改个版本号跑一遍,比手工敲命令可靠得多,也方便交接给同事。脚本里记得加set -e,任何一步失败就停,别让半成品 deb 流出去。