干过这类事的朋友应该能理解,接到“在 Windows 上打一个 arm64 的 deb 安装包”这种需求的时候,第一反应多半是有点懵的。我这次的任务,是给一台跑 Debian 系统的 ARM 架构设备发布一个命令行小工具,但我的开发机是一台 Windows 笔记本。折腾完整个流程之后回头复盘,真正绕弯的地方其实不在“Windows”也不在“deb”本身,而在三个我一开始想当然的假设:觉得 deb 必须上 Linux 才能打、觉得 arm64 只是把包描述里的架构字段改一改就行、觉得包打出来就等于能装、能跑。这篇就把这三个误区摊开讲清楚,同时把我最后跑通的完整流程放出来,供同样在 Windows 上做 Linux 包交付的兄弟参考。
1. 任务背景与方案选型
1.1 这到底是个什么任务
先交代一下场景。目标设备是 ARM64(AArch64)架构的 Debian 板子,跑的是精简版系统,没有图形界面,我用一个小型命令行工具去采集设备状态并通过 HTTP 上报。交付方式是 .deb 安装包,这样运维同学拿到包以后执行dpkg -i或者apt install ./xxx.deb就能装好,卸载也很干净。
开发机上装的是 Windows 11,项目仓库在 Git 里,代码本地编译能过。问题来了:我需要产出的,是一个给“Linux + ARM 架构”用的安装包,而我的开发环境是“Windows + x86_64”。任务拆开看其实是三件事:先为 arm64 Linux 交叉编译出可执行文件,再把这个文件按照 deb 包的规范封装成归档,最后在目标设备上实测安装与运行。
这一步接一步的问题,分别踩中了那三个误区。
1.2 三条打包路线的对比
我在动手之前简单列了一下可行方案,各有利弊,我直接把关键参数和取舍列出来:
| 方案 | 核心工具 | 风险与成本 | 适用场景 |
|---|---|---|---|
| A:WSL 2 里完成一切 | WSL 发行版 + dpkg-deb / lintian | 依赖 WSL 环境,路径转换时有坑,但最接近原生 Linux | 经常持续交付 Linux 包的团队 |
| B:Docker 容器挂载目录 | Linux 容器 + fpm / dpkg-deb | 需要 Docker Desktop,容器与宿主机路径分离,但环境可复现 | 配合 CI 或本地一键构建 |
| C:Windows 原生打包 | Ruby + fpm,或手动构造 ar 归档 | fpm 对环境依赖多,权限位处理麻烦 | 偶尔打一包、不想装 Docker 时使用 |
我最后选的是方案 B 为主、方案 C 备用的混合办法。理由很简单:Docker 容器里是完整的 Linux 环境,dpkg-deb、lintian、fpm这些工具都是官方的,连交叉编译的gcc-aarch64-linux-gnu也能直接 apt 装上;对 Windows 而言,只要求装个 Docker Desktop,把项目目录挂载进去,构建产物再落回 Windows 磁盘,等于既用了 Linux 的打包工具链,又没离开 Windows 的工作习惯。
1.3 为什么坚持“在 Windows 上”完成
有人会问:既然都要用 Docker 了,为什么不干脆在 Linux 虚拟机里做?我当时的实际约束是:团队协作和文件流转都围绕 Windows 办公环境,运维交付流程里需要从我的开发机直接拿到产物并做签名登记;临时再拉一台 Linux 虚拟机,反而要多维护一套环境。
而且这个需求不是一次性任务,之后每次版本迭代都要在 Windows 上重新打 arm64 deb 包。所以我要的不是“这一次能打出来”,而是一条可重复执行的构建路径。Docker 方案的自然好处是,把整个打包流程写成一个脚本或 Dockerfile 后,Windows 上的任何一台机器都能复现代,不用再折腾环境。
2. 第一个误区:deb 包只能在 Linux 环境里打
2.1 deb 包的物理结构到底是什么
先打破一个心理障碍:deb 不是一个神秘的“Linux 专属格式”,它本质上就是一个 Unix 世界的归档文件。一个标准的 deb 包内部由三部分组成:
debian-binary:纯文本文件,内容通常是2.0,表示包格式版本;control.tar.gz:存放软件包元信息,比如control、md5sums、conffiles;data.tar.xz:实际要安装到系统里的文件,按根目录结构排列,比如usr/bin/xxx。
这三部分被ar归档工具封装成一个整体。Linux 下的dpkg-deb做的事情,本质上就是“组装这个归档”和“从归档里还原文件”。理解了这一点,就会明白:只要能在某种环境下构造出这三部分内容,deb 包就能打出来,不一定非得是 Linux 操作系统本身。
我当时想当然地把“deb”和“Linux 桌面环境”绑在一起,以为离开 Linux 就寸步难行。其实 Windows 上完全可以手工构造 ar 归档,只是这样太原始、容易出错,不值得在生产流程里用。
2.2 在 Windows 上干这件事的三条路
第一条,Ruby + fpm。fpm是一个把任意目录变成 deb、rpm 等格式的打包工具,在 Windows 上装上 Ruby 和 DevKit 后,gem install fpm就能用。它能在 Windows 文件系统上直接生成 deb,不需要 Linux。但是有个经典坑:Windows 的 NTFS 没有 Unix 权限位,fpm 打包时不能自动识别“这个文件是否可以执行”,所以最终 deb 里的可执行文件可能没有+x权限,装到 Linux 上直接“Permission denied”。
第二条,Docker 容器。这是我最推荐的方式。Windows 上的 Docker Desktop 可以无缝启动 Linux 容器,容器内部就是完整的 Debian/Ubuntu 环境。把 Windows 的项目目录用-v D:\project:/build方式挂载进去,在容器里安装dpkg-dev、fpm、gcc-aarch64-linux-gnu,然后执行打包命令,生成的 deb 文件会落在 Windows 磁盘上。权限问题也能顺手解决,因为容器内文件系统是真正的 Linux 文件系统,chmod +x生效。
第三条,WSL 2。装了 WSL 2 之后,子系统本身就是 Linux,路径直接就能访问 Windows 盘,用起来最像“在 Linux 里”。但如果你已经在用 Docker Desktop,它底层多半也是 WSL 2 后端,再开一个发行版实例意义不大;而且 WSL 的发行版如果只是临时用,维护成本略高。
2.3 我最终选择容器方案的原因
我选容器方案,还有一个很实际的理由:交叉编译器。我的目标设备是 arm64,如果程序是非解释型的编译产物,必须用交叉编译器生成 ARM 指令集的 ELF。Windows 原生的交叉编译工具链不是没有,但配置起来相当折腾,而 Debian 容器里一条apt install gcc-aarch64-linux-gnu就全齐了。后面小节会详细讲,但这里先点一句:打包很快,真正决定成败的是你有没有把“能在目标架构上跑的二进制”放进去。
3. 第二个误区:arm64 不只是一个字段
3.1 交叉编译的准备工作
这是我这次最深刻的一个教训。我当时以为,只要在 fpm 或者 dpkg 的时候把Architecture字段写成arm64,包打出来就能用。现实是:架构字段只负责“告诉 apt 这个包装到哪种系统上”,它不负责“让包里那个文件真的能在 ARM 上运行”。
deb 包里放的如果是编译好的二进制程序,那这个文件必须已经是 ARM64 指令集的 ELF。换句话说,在 Windows 的GOARCH=amd64下编译出来的程序,即使包名写着_arm64.deb,装到 ARM 设备上也会直接报“cannot execute binary file: Exec format error”。
提前准备的工作很简单:确认目标架构名。Debian 系官方架构名是arm64,对应 AArch64;Android 上常见的arm64-v8a不能直接搬过来用,因为 apt 认识的是arm64。我一开始受过 Android 习惯影响,差点写成arm64-v8a。
3.2 不同语言工具的交叉编译做法
如果你的交付物是二进制程序,具体准备方式取决于语言:
Go 是最省心的。设置两个环境变量即可:
$env:GOOS="linux" $env:GOARCH="arm64" $env:CGO_ENABLED="0" go build -o mytool .建议关掉 CGO,这样产物是纯静态链接,不依赖目标机的 glibc,装哪都能跑。如果必须开启 CGO 并依赖 C 库,那就需要配合aarch64-linux-gnu-gcc,在 Windows 上直接交叉编译会麻烦很多。
Rust 的步骤也很标准,安装目标后编译即可:
rustup target add aarch64-unknown-linux-gnu cargo build --release --target aarch64-unknown-linux-gnu注意,aarch64-unknown-linux-gnu这个目标默认是动态链接到 glibc 的。如果你的目标设备是精简镜像、glibc 版本较老,同样可能遇到运行库版本不匹配的问题。想走得稳,可以研究aarch64-unknown-linux-musl的静态目标。
C/C++ 项目通常需要明确的交叉编译工具链,在 Debian 容器里就是:
apt install gcc-aarch64-linux-gnu aarch64-linux-gnu-gcc -static -o mytool main.c-static能避免目标设备缺共享库的麻烦;如果坚持动态链接,至少要在目标设备上确认相关.so都在,这个排查成本比较高。
3.3 最容易翻车的点:动态链接和架构识别
我用 Go 写的工具,因为默认静态链接,当时信心满满。但后来在容器里跑了一个file命令才踏实:
file mytool # 输出形如:ELF 64-bit LSB executable, ARM aarch64, statically linked如果你看到dynamically linked,就必须继续查它依赖了哪些库。可以执行:
readelf -d mytool | grep NEEDED我就在这个问题上栽过:某一次用 C 写的小工具没有加-static,动态链接到了容器里较新版本的libc.so.6,而目标设备的libc6版本比较老,安装时依赖都没问题(因为控制信息只声明了libc6,没有精确到版本),一运行就报/lib/aarch64-linux-gnu/libc.so.6: version 'GLIBC_2.34' not found。这个真的要提前查清楚。
3.4 顺带提醒:脚本类程序也要注意架构
如果你的包里的主程序是 Python 脚本或者 Shell 脚本,没有二进制的架构问题,但依赖的第三方 Python 包可能包含编译好的.so,这时候还是得要求“依赖里写明 Python 版本和平台”。所以“arm64 只是个字段”这个想法,对纯脚本成立,对编译产物和含原生扩展的脚本都不成立。
4. 第三个误区:deb 打出来不等于能安装
4.1 可执行权限是怎么丢的
这是我在 Windows 上打包遇见的最具欺骗性的问题。deb 包里的文件在安装时会按照归档里记录的权限位还原到系统里,如果原始归档里没有标记可执行权限,装完以后/usr/bin/mytool的权限是-rw-r--r--,用户执行时就只能看到Permission denied。
Windows 上为什么容易丢权限?因为 NTFS 文件系统没有完整的 Unix 权限模型,你从 Windows 复制到 Linux 容器或者用 Windows 工具做归档时,+x信息本来就是缺失的。我在第一次试打包时,用 fpm 直接在 Windows 里指向一个打包好的目录,出来的 deb 装到 Linux 上就是“没有执行权限”。
解决办法其实不复杂:在容器内归档之前,对可执行文件执行一次chmod +x。用 fpm 时,也可以显式声明模式:
fpm -s dir -t deb \ --name mytool \ --version 1.0.0 \ --architecture arm64 \ --deb-user root \ --deb-group root \ --deb-file-mode 755 \ ./mytool=/usr/bin/mytool如果不用 fpm,而是用dpkg-deb,则需要在容器里保证源目录内权限正确后再执行dpkg-deb --build --root-owner-group root ./package_dir。
4.2 架构字段和版本号的坑
架构字段前面讲过,必须写arm64。而版本号也是一个容易出错的地方。Debian 的版本号规则是[epoch:]upstream_version[-debian_revision],upstream_version只允许数字、字母和. + - ~这几个符号,不允许下划线。如果你习惯性地传--version 1.0.0_beta,fpm 会自动把下划线变成别的符号,或者直接报错,安装时会提示 “bad version”。
我建议版本号一律保持a.b.c或a.b.c-build这种格式,简单不容易错。
4.3 包内路径结构
deb 包内的文件路径是相对根目录展开的。也就是说,你希望程序最终落在/usr/bin/mytool,归档里的路径就应该是usr/bin/mytool,不能带前导/,也不能是 Windows 风格路径D:\...。我在构建目录时,采用的是先做好包含usr/bin结构的 staging 目录,再把整个usr目录放进去,这样结构清晰、不容易乱。
4.4 安装前必须跑的验证清单
我最后摸索出一个固定流程,每次打完包都先做一遍,可以提前拦截大部分问题:
- 用
dpkg-deb --info mytool_1.0.0_arm64.deb检查元信息,确认 Architecture、Depends、Size 等; - 用
dpkg-deb -c mytool_1.0.0_arm64.deb检查归档内的路径和权限位; - 在容器内实际执行
dpkg -i或apt install ./mytool_1.0.0_arm64.deb,看有没有依赖问题; - 安装后直接执行
which mytool && mytool --version,确认可执行位和程序本身没问题。
如果是干净容器,直接docker exec进容器里安装验证就行。这比“打完包就发出去”的信任感要强得多。
5. 完整实操流程:从 Windows 到 ARM 设备
5.1 工具准备清单
我在 Windows 上准备这些东西,一次性解决环境问题:
- Docker Desktop,并确保 Docker 能启动 Linux 容器;
- 项目代码目录,比如
D:\work\mytool; - 一个用于交叉编译和打包的 Debian 镜像基础,我用的是
debian:bullseye或debian:bookworm都可以。
Docker Desktop 在 Windows 上第一次跑 Linux 容器会有点慢,但第二次开始就快了。用 Docker 而不是直接用 WSL 单独维护环境,是为了让整个构建流程可以放进一个脚本里,后续任何同事在 Windows 上都能一键复现。
5.2 一个完整的构建脚本
我最终落地的方式是:先写一个小的构建脚本,然后在 Windows 上用 Docker 挂载目录执行。下面是脚本的主干,你可以根据项目改。
#!/bin/bash set -e # 1. 编译准备 cd /build export GOOS=linux export GOARCH=arm64 export CGO_ENABLED=0 # 2. 交叉编译 Go 程序 go build -o /out/mytool . # 3. 组装 staging 目录 mkdir -p /work/usr/bin cp /out/mytool /work/usr/bin/mytool chmod +x /work/usr/bin/mytool # 4. 制作 deb 包 cd /work fpm -s dir -t deb \ -n mytool \ -v 1.0.0 \ -a arm64 \ --deb-user root \ --deb-group root \ --deb-file-mode 755 \ --description "A small tool for device status collection" \ --url "https://example.com/mytool" \ --license "MIT" \ -p /out/mytool_1.0.0_arm64.deb \ ./usr=/usr # 5. 验证 dpkg-deb --info /out/mytool_1.0.0_arm64.deb dpkg-deb -c /out/mytool_1.0.0_arm64.deb注意-p /out/...指定输出路径,/out是容器内挂载点,对应 Windows 上的某个目录。假设项目在D:\work\mytool,保存脚本为build.sh后,在 Windows PowerShell 里执行:
docker run --rm -v D:\work\mytool:/build -v D:\work\output:/out -w /build debian:bookworm bash -c "apt update && apt install -y golang ruby ruby-dev build-essential dpkg-dev && gem install fpm --no-document && bash build.sh"这行命令会把整个构建过程自动化:容器里自动装好 Go、Ruby、fpm,然后执行脚本,产物直接写到D:\work\output。容器的沙箱化环境还有个额外好处:不会把 Windows 本地的 PATH 变量污染带进去,编译环境每次都是干净的。
5.3 实际安装验证的日志长什么样
为了说明效果,我把验证步骤贴出来。进入目标设备或容器:
apt install ./mytool_1.0.0_arm64.deb正常输出大体是:
Reading package lists... Done Building dependency tree... Done Reading state information... Done Selecting previously unselected package mytool. (Reading database ... 52348 files and directories currently installed.) Preparing to unpack mytool_1.0.0_arm64.deb ... Unpacking mytool (1.0.0) ... Setting up mytool (1.0.0) ...然后执行:
which mytool # /usr/bin/mytool mytool --version # mytool version 1.0.0如果能走到这一步,说明架构、权限、依赖、路径这四关都过了。我自己的经验是:第一次真正的失败就是在最后一步,mytool --version直接Permission denied,当时立刻意识到是权限问题。
5.4 从 Windows 到 ARM 设备
整个链条的最后一段,是把 deb 文件传到目标设备。我在 Windows 上常用scp(OpenSSH 自带):
scp D:\work\output\mytool_1.0.0_arm64.deb user@device:/tmp/目标设备上执行安装即可。如果你的网络环境内网有 apt 仓库,也可以先把 deb 放到仓库里再apt install mytool,但这不是本文重点。
6. 常见问题排查表格与避坑心得
6.1 高频问题速查表
我把这次实际操作中遇到过的、以及同行常踩的问题整理成一个表,方便直接对照:
| 现象 | 根本原因 | 处理方式 |
|---|---|---|
安装后执行报Permission denied | 文件没有可执行权限 | 容器内chmod +x,或 fpm 参数--deb-file-mode 755 |
安装时报package architecture (amd64) does not match system (arm64) | deb 内 Architecture 字段错误 | fpm 加-a arm64,或用 dpkg-deb 时确保 control 里写arm64 |
运行时提示cannot execute binary file: Exec format error | 二进制不是 ARM64 指令集 | 重新交叉编译;用file命令检查产物格式 |
安装时提示缺少依赖,比如libc6 (>= 2.34) | 动态链接到高版本 glibc | 尽量静态链接;或确认目标设备 glibc 版本 |
| 安装完成后找不到命令 | 路径结构不对 | 检查 deb 包内路径是否usr/bin/, 不要带前导/ |
dpkg -i执行时提示bad version | 版本号含下划线等非法字符 | 版本号严格用字母、数字、.、-、~ |
| Windows fpm 打包后 deb 能安装但程序无法运行 | 可能权限/架构/依赖之一有问题 | 优先在 Linux 容器内复核全套四个验证命令 |
6.2 几个零碎但很容易忽略的提醒
第一个是符号链接。如果你的软件包内需要放符号链接,比如一些服务习惯把配置指向.config,用 Windows 工具做归档时,符号链接经常会被当成普通文本文件。容器内制作 staging 时,用 Linux 的ln -s生成真实符号链接,验证时用ls -l检查是否显示->,这一步别省略。
第二个是关于md5sums控制文件。dpkg-deb --build会自动生成,但如果你用 fpm,它也会自动处理。不需要手工维护,只是提醒你:不要在打包过程中随意修改归档内的控制文件,否则会造成安装校验问题。
第三个小技巧:多次打包后,我发现把“验证命令”也放进同一个脚本,出问题的概率会显著下降。不要省这几十秒,验证命令不需要执行到目标设备上,只需要在干净容器里跑一次apt install ./xxx.deb,就能拦截大部分低级错误。
6.3 我这次印象最深的一条教训
要说印象最深,其实是“权限陷阱”不是架构陷阱。我一开始花了大量时间研究交叉编译,觉得 arm64 是最难的关。实际上,Go 的交叉编译一行命令就解决了;反而是 Windows 打包时代理出来的权限问题,让我在最后一步栽了跟头。这个事很典型地说明了一件事:做跨平台交付的时候,对“源平台”和“目标平台”差异的盲区,往往才是真正的敌人。
最后再分享一个小经验
如果你也经常要在 Windows 上做 Linux 包交付,我强烈建议把整套流程沉淀成脚本加一个简单的说明文档,放进仓库的build/目录里。我这次用的脚本,后续同事直接复制到自己的 Windows 机器上,改一下版本号就能跑,不用重新摸索。打包这事看着不复杂,但每一步的“想当然”都可能让最终产物在目标设备上崩掉;把验证步骤固化下来,比临时记在脑子里可靠得多。以后再遇到类似需求,我也会第一时间先确认目标架构和链接方式,而不是急着动手打包。