前阵子给手里的 RK3568 开发板做工具链适配,遇到一个特别典型的场景:代码在 Windows 侧写好、在 WSL 里编译,最终产物要发给好几块板子,还要能统一升级和卸载。如果只是把交叉编译出来的二进制直接 scp 过去,开发环境里自己用一用还行,一旦涉及交付给别人、给系统加启动项、做依赖管理,散装文件是会出事的。最省心的做法就是把 arm64 的 deb 包打出来,到板子上执行一条 dpkg -i 就完成安装。
这篇博客完整记录的就是这个流程:Windows 上装好 WSL,在 WSL 里用 aarch64 交叉编译工具链把 C 程序编译成 ARM64 二进制,按 deb 包规范组织目录和 control 文件,用 dpkg-deb 打出 .deb,最后传到板子上一条命令装完并验证运行。整个链路听起来长,实际拆开每一步都不复杂,但坑点相当密集:工具链装错、架构标错、文件属主不对、control 字段缺失、依赖版本不匹配……任何一个环节踩雷,dpkg -i 都会非常不给面子地当场报错。
这篇文章适合手里有 RK3568、树莓派 4B/5 或者各种 ARM64 架构 Linux 开发板,平时主用 Windows 做开发的人,也适合想系统理解“交叉编译 + deb 打包”这两个概念的新手。我会把每一步的为什么也讲清楚——比如为什么选 WSL 而不是虚拟机、为什么 deb 优于散装二进制、为什么 Architecture 字段必须写 arm64——这样你读完不只是会抄,还能自己排查问题。
1. 先想清楚:为什么偏偏是 WSL + 交叉编译 + deb 的组合
1.1 场景决定技术方案
先说场景。嵌入式 Linux 开发里常常见到两种交付形态:一种是整机方案,固件、内核、根文件系统都做好了,烧录到板子上就完事;另一种是应用交付,板子已经装了现成的 Debian 系系统(Ubuntu、Debian、Armbian 都算),你只需要把某个应用或者工具装上去。本文讲的是后者。
在这种形态下,我见过不少团队还在用最原始的方式:在板子上直接 git clone 源码再用 gcc 编译。好处是没有交叉编译的架构问题,坏处也很明显——板子的 CPU 性能普遍一般,编译一个稍大点的项目动辄十几分钟,而且系统里要装完整 build-essential,为了编译工具把板子环境搞得乱七八糟。更要命的是,如果板子要交给非技术用户,你不可能让每个人都去跑一遍编译。
反过来,在 Windows 上直接做 ARM 交叉编译也不是不行,但 Windows 原生生态里很难找到一个和 Debian 系系统行为一致的编译环境。Windows 下交叉编译器(比如 Linaro 那套)能装,但编译器版本、sysroot、头文件和库文件的组织形式,与 Debian 系的惯例总是差着一层。编译时没问题、到板子上链接出错的情况时有发生。
所以 WSL 是最顺手的桥:WSL2 跑的是一个真正的 Linux 内核和发行版,里面的 apt、gcc、库路径全部和你板子上的 Debian 系系统同源。你在 WSL 里能做的事,和在一台 x86 的 Ubuntu 服务器上能做的事几乎完全一样。编译环境的差异被压到最小,剩下的只有架构差异,交给交叉编译器处理。
1.2 deb 包到底解决了什么问题
很多人第一反应是:我都交叉编译出二进制了,直接 tar 包传过去解压不行吗?行,但只对自己行。deb 包格式的好处,恰恰体现在那些“一个人用”时感觉不到的环节上:
- 依赖声明。control 文件里写 Depends 字段,安装时 dpkg 会自动检查板子上有没有对应的库、缺不缺某个最低版本。省得用户装上之后运行时报“找不到 libxxx.so.1”,你还要远程教他排查。
- 卸载干净。dpkg -r 能把文件按清单删掉,维护脚本还能帮你清理符号链接、缓存文件。tar 包散装文件卸载时谁也说不清哪些该删。
- 权限和属主预设。deb 包内的文件可以准确标记 755/644 和属主,包管理器负责在安装时还原。这比手动 chmod 可靠得多。
- 生态一致。像 ToDesk、网易云音乐这些面向国产开发板的软件,官方发的就是 arm64 的 deb 包;dropbear 这类嵌入式 ssh 服务,很多镜像里也是以 deb 形式集成的。你的工具用同样的格式发布,别人安装你的工具的成本就是一行 dpkg -i。
所以结论很直白:交叉编译解决“在哪编译”的问题,deb 打包解决“怎么发布”的问题,WSL 把这两件事在 Windows 上串了起来。
2. 环境准备:装 WSL 和 arm64 交叉编译工具链
2.1 WSL 2 安装的几个关键点
如果你机器上还没装 WSL,先确认虚拟化功能已经开启。Windows 10 较新版本和 Windows 11 都支持 WSL2,在管理员 PowerShell 里执行一条命令:
wsl --install这条命令会一次性完成:启用 WSL 和虚拟机平台组件、下载并安装 WSL2 内核、安装默认的 Ubuntu 发行版。默认装的是 Ubuntu 最新 LTS,做交叉编译完全够用。如果安装过程中下载很慢,或者中途被重置,可以在命令后面加--web-download,让安装器直接从微软官方网站下载,而不是从商店拉取,很多时候会快不少。我遇到过的wsl/installdistro/service/registerdistro/createvm/hcs/error_file_n这类错误码,绝大多数是虚拟化没开或者机器上还有残留旧版 WSL 组件。确认 BIOS 里 VT-x/AMD-V 开启、把 Windows 的虚拟机平台功能勾选好,基本就能解决。
还有一个经常被忽略的点:默认发行版会装在 C 盘,wsl --install 之后系统分区会被吃掉几个 GB。如果 C 盘紧张,在装完 Ubuntu 后可以把它导出再导入到 D 盘:
# 先备份 wsl --export Ubuntu D:\wsl-backup\ubuntu.tar wsl --unregister Ubuntu # 再恢复到 D 盘目录 wsl --import Ubuntu D:\WSL\Ubuntu D:\wsl-backup\ubuntu.tar这个操作不复杂,但要在装好环境、配好工具链之前做,不然导来导去还得重配。另外强烈建议所有项目代码放在 WSL 内部文件系统(比如~/work),不要放在/mnt/c下,原因我会在第 5 章详细讲。
2.2 安装交叉编译工具链
WSL 里的 Ubuntu 装好后,打开 Ubuntu 终端,先更新软件源,再装交叉编译工具链:
sudo apt update sudo apt install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu binutils-aarch64-linux-gnu这几个包把 C/C++ 交叉编译器、对应的 binutils(as、ld、strip、objdump)都带齐了。核心工具对应关系如下:
| 工具 | 作用 | 安装包名 |
|---|---|---|
| aarch64-linux-gnu-gcc | ARM64 交叉 C 编译器 | gcc-aarch64-linux-gnu |
| aarch64-linux-gnu-g++ | ARM64 交叉 C++ 编译器 | g++-aarch64-linux-gnu |
| aarch64-linux-gnu-ld / as | 交叉链接器 / 汇编器 | binutils-aarch64-linux-gnu |
| aarch64-linux-gnu-strip | 去除符号表 | binutils-aarch64-linux-gnu |
| file | 查看 ELF 架构 | file(系统自带) |
安装完先验证:
aarch64-linux-gnu-gcc --version正常会输出版本号,比如 gcc version 11.4.0。这里有个新手容易混淆的点:不要以为装完gcc-aarch64-linux-gnu,在 bash 里敲gcc就会自动变成交叉编译器。本机的 gcc 编译出来的还是 x86_64 二进制,只有带aarch64-linux-gnu-前缀的那几个工具才会产出 ARM64 代码。这个差异很重要,后面我会用 Makefile 里的 CROSS_COMPILE 变量把它固化下来。
3. 交叉编译实操:写一个能在板子上跑的小工具
3.1 示例工具选型:一个系统信息收集器
为了把流程讲透,我选一个很实用的示例:写一个 device-info 小工具,跑在 RK3568 板上能输出 CPU 型号、温度、内存信息。这个工具体量小、不依赖第三方库,适合演示,而且它读取 /sys/class/thermal、/proc/meminfo 这些文件,能顺带验证目标板子上的内核接口是否正常。
源码 device-info.c:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/utsname.h> static void read_first_line(const char *path, char *buf, size_t size) { FILE *fp = fopen(path, "r"); if (!fp) { snprintf(buf, size, "N/A"); return; } if (!fgets(buf, size, fp)) { snprintf(buf, size, "N/A"); } buf[strcspn(buf, "\n")] = '\0'; fclose(fp); } int main(void) { struct utsname u; char model[256], temp[64], mem_total[64]; if (uname(&u) == 0) { printf("Kernel: %s %s\n", u.sysname, u.release); printf("Machine: %s\n", u.machine); } read_first_line("/proc/device-tree/model", model, sizeof(model)); printf("Model: %s\n", model); read_first_line("/sys/class/thermal/thermal_zone0/temp", temp, sizeof(temp)); printf("CPU Temp: %s mC\n", temp); read_first_line("/proc/meminfo", mem_total, sizeof(mem_total)); printf("Mem: %s\n", mem_total); return 0; }这段代码很直白。注意 /proc/device-tree/model 在大多数 ARM64 板子上都能读到,但有些精简内核里没挂 device tree,读不到会输出 N/A,不影响程序继续跑。
3.2 Makefile 与编译命令
项目根目录放一个 Makefile,把交叉编译器的前缀抽出来:
CROSS := aarch64-linux-gnu- CC := $(CROSS)gcc CFLAGS := -O2 -Wall -march=armv8-a+crc LDFLAGS := -s all: device-info device-info: device-info.c $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) clean: rm -f device-info .PHONY: all clean这里解释几个参数为什么这么配:
-march=armv8-a+crc:RK3568 是 Cortex-A55 核心,ARMv8.2-A 指令集,完全兼容 ARMv8-A。用 armv8-a 作为基线很安全;加上 +crc 是因为大多数 Debian 仓库里的库带了 CRC 指令优化,保持和系统库一致的架构基线,能在链接时少一些莫名其妙的兼容问题。-O2:稳定优化档,别一开始就用 -O3,浮点、字符串这类代码在一些老内核上可能踩到未定义行为。- LDFLAGS 里的
-s:等价于编译完自动 strip。交叉编译器默认生成的 ELF 带完整符号表,体积比较大,strip 之后能明显缩小。虽然这点体积对现代板子无所谓,但养成习惯是好的。
然后执行:
make file device-infofile 输出里如果能看到ELF 64-bit LSB executable, ARM aarch64,说明交叉编译成功。这是整个流程第一个必须验证的里程碑。如果你看到的是x86-64,别犹豫,肯定是用了本机 gcc。
3.3 静态链接还是动态链接:这里要想清楚
刚才的 Makefile 默认是动态链接,依赖目标板子的 glibc。x86 电脑上你不需要关心 glibc 版本,但 ARM64 板子系统版本五花八门:有的 Ubuntu 20.04 用 glibc 2.31,有的 Armbian 基于 Debian 11 用 glibc 2.31,有的是 Debian 12 的 2.36。交叉编译时用的是 WSL 里的 glibc(版本比较新),动态链接产物拿到老系统上可能报GLIBC_2.34 not found这类错误。
三个常见解法,按推荐度排序:
- 在更老的发行版里做交叉编译,比如 WSL 里装 Debian 11,编出来的动态链接产物对低版本 glibc 兼容性更好。但维护成本高。
- 编译时给 gcc 传
--sysroot,指定目标系统的根文件系统,用目标板子的 libc 来链接。这个最严谨,但需要先把板子上的库同步到 WSL,复杂度上升。 - 静态链接。直接把 libc 编进二进制,任何板子拿来就能跑。缺点是体积大、安全更新不生效。
对 device-info 这种小工具,静态链接是最省心的。把 Makefile 里的 LDFLAGS 改成 -static 再编:
make clean && make LDFLAGS=-static file device-info你会看到 file 输出变成statically linked,体积可能到 700KB 左右,在板子上随便哪台机器跑都稳。比较大的应用就不建议静态链接了,第 6 章会讲动态依赖的正式解法。
4. 打包 deb:从二进制到可安装包
4.1 deb 包的核心结构:DEBIAN 目录和文件树
deb 包本质上是一个 ar 归档,内核是两层东西:DEBIAN 目录存放包元数据和安装脚本,其余目录(usr、etc、lib 等)就是安装时会被解压到系统根目录的文件树。打一个包,核心是把这两层组织对。
我常用的工作目录长这样:
device-info_1.0.0_arm64/ ├── DEBIAN/ │ ├── control # 包元数据,必填 │ ├── postinst # 安装后脚本,可选 │ ├── postrm # 卸载后脚本,可选 │ └── md5sums # 文件校验,可选但推荐 └── usr/ ├── bin/ │ └── device-info # 交叉编译产物 └── share/ └── doc/ └── device-info/ └── README # 文档,可选目录命名直接采用包名_版本_架构这种三段式,打出来的 deb 文件名就是目录名加 .deb,dpkg 和 apt 都认这个命名习惯。
4.2 control 文件字段逐个讲
DEBIAN/control 是包的灵魂,每个字段都有讲究:
Package: device-info Version: 1.0.0 Section: utils Priority: optional Architecture: arm64 Maintainer: Your Name <you@example.com> Depends: libc6 (>= 2.31) Description: Display board hardware info on ARM64 Linux Collect CPU model, thermal zone temperature and memory info.各字段的作用和注意点:
| 字段 | 说明 | 常见坑 |
|---|---|---|
| Package | 包名,只能小写字母、数字和 - | 和目录名不一致会导致 dpkg 无法匹配安装记录 |
| Version | 版本号,符合 Debian 版本策略 | 升级时 dpkg 按字符串比较,建议用 1.0.0 这种三段式 |
| Architecture | 目标架构,ARM64 板必须写 arm64 | 写错成 amd64 就会遇到“架构不匹配”硬报错 |
| Depends | 依赖声明 | 动态链接的工具至少要写 libc6,版本要兼容目标板 |
| Maintainer | 维护者信息 | 格式不规范时校验工具会告警 |
| Description | 短描述 + 长描述 | 首行建议一句话,之后用空格缩进写详细说明 |
Architecture 这个字段是整个 deb 包最容易出错的地方。ARM64 在 Debian 系里的架构标识就是arm64,不是 aarch64,也不是 arm64el。你交叉编译时用的工具链前缀叫aarch64-linux-gnu,但 deb 元数据里的架构名必须写arm64,这是两套独立的命名体系,别混。
4.3 维护脚本 postinst / postrm 的用法
control 文件描述“包是什么”,postinst、postrm 这些脚本描述“安装/卸载时系统该做什么”。对多数小工具,postinst 的典型用途是刷新动态链接器缓存:
#!/bin/sh set -e if [ -x /sbin/ldconfig ]; then /sbin/ldconfig fi exit 0postrm 一般写清理逻辑,比如卸载时删除配置目录:
#!/bin/sh set -e rm -rf /etc/device-info exit 0这里有个很容易踩的坑:写脚本时不加set -e。deb 维护脚本如果某条命令返回非零,dpkg 会认为安装失败并开始回滚。加了set -e后脚本出错时能立刻停下,便于排查。另外,脚本文件必须带可执行权限,不然 dpkg 会直接无视它。
4.4 打包命令与根属主问题
打包前先算 md5sums(可选,但我建议加上),并且保证目录树里文件的权限正确。二进制是 755,文档是 644:
cd device-info_1.0.0_arm64 find usr -type f -exec md5sum {} \; > DEBIAN/md5sums chmod 755 DEBIAN/postinst DEBIAN/postrm然后回上层目录执行:
dpkg-deb --build --root-owner-group device-info_1.0.0_arm64--root-owner-group这个参数很重要。如果不写它,在 WSL 里 dpkg-deb 会用 WSL 当前用户的 UID/GID 记录包内文件属主,比如 uid 1000。你在 Windows 下的 WSL 里可能意识不到这个问题,但包传到板子上安装时,文件属主会变成 1000,而不是正常的 root。加了这个参数,打包时统一按 root 属主记录,安装时就正确了。
打完后检查:
ls -l device-info_1.0.0_arm64.deb dpkg-deb --info device-info_1.0.0_arm64.deb dpkg-deb --contents device-info_1.0.0_arm64.deb--info显示 control 元数据,--contents列出包内文件。这两个命令在任何机器上都能跑,是排查打包问题最趁手的工具。看到 Architecture: arm64、文件树符合预期,这个包就算成了。
5. 常见问题与排查实录
5.1 打包和安装环节的高频报错速查
我在整个流程里踩过的、以及帮别人排查过的问题,整理成一张表:
| 症状 | 根因 | 解决方法 |
|---|---|---|
| dpkg -i 时报 package architecture (arm64) does not match system (amd64) | 在 x86 WSL 里本地安装 arm64 包 | 这不是故障,说明交叉编译成功。要装到 ARM64 板子上,别在 WSL 里装 |
| file 显示 x86-64 | Makefile 没用交叉编译器 | 检查 CROSS 变量是否传入,别直接用 gcc |
| dpkg-deb 报 buffer_read / 权限相关错误 | 打包目录文件权限不对或目录被 WSL 锁定 | 把项目挪出 /mnt/c,放到 ~/ 下重试 |
| postinst 没执行 | 脚本没有可执行权限 | chmod 755 DEBIAN/* |
| 板子上运行报 GLIBC_2.34 not found | 动态链接的 libc 版本高于板子 | 静态链接,或使用更老的工具链 |
| dpkg: dependency problems prevent configuration | Depends 字段版本写太高 | 确认板子上实际 libc 版本,调低 Depends 或去掉 |
| 安装后 which 不到 device-info | Package 字段和目录名不一致 | 检查 control 的 Package 字段,重新打包 |
5.2 WSL 本身的坑:文件系统在哪里决定了你顺不顺
WSL 最大的坑在文件系统。Windows 的 C:\ 盘在 WSL 里挂在 /mnt/c,但跨文件系统的 IO 性能很差,而且权限模型和 Linux 原生不一样。你在 Windows 资源管理器里把源码拖到 /mnt/c 下,再到 WSL 里 make,会遇到两个典型问题:编译慢(一个几十文件的项目能慢到无法忍受)、脚本执行时权限不对(Windows 文件系统不认 Linux 的 chmod)。
正确做法是把整个工作区放在 WSL 的 Linux 侧,比如~/work/device-info,用 mkdir 创建,用 VS Code 的 Remote-WSL 插件打开。这样编译、打包、权限都按 Linux 的规则走,Windows 侧想访问文件,就用资源管理器路径\\wsl$\Ubuntu\home\你的用户名\work。
5.3 先用 qemu 在 WSL 里预验证
如果你不想在打到板子之前才发现低级错误,WSL 里可以装 qemu-user:
sudo apt install -y qemu-user ./device-infoqemu-user 会用软件模拟的方式执行 ARM64 ELF,输出和真机基本一致。用它跑一遍 device-info,至少能确认程序逻辑、sysfs 路径读取没有低级错误。虽然性能和真机不同,但用于冒烟测试足够了。这个技巧还可以用来验证 deb 包里的 postinst 逻辑,避免把错误脚本发到板子上。
6. 上板安装与验证
6.1 把 deb 包传到板子上
打好的 device-info_1.0.0_arm64.deb 传输方式很多,最常用的是 scp。假设板子 IP 是 192.168.1.100,用户名以你的系统为准:
# 在 WSL 里执行 scp device-info_1.0.0_arm64.deb pi@192.168.1.100:/tmp/如果板子上还没有 sshd,可以在 Windows 侧直接把 deb 文件复制到 U 盘,插到板子上挂载后拷贝。注意 deb 包传到 /tmp 后先ls -l确认属主和权限,避免因为文件在 Windows 和板子之间倒腾导致属主异常,影响 dpkg 安装。
6.2 一行 dpkg -i 安装并验证
板子上执行:
sudo dpkg -i /tmp/device-info_1.0.0_arm64.deb正常的输出长这样:
Selecting previously unselected package device-info. (Reading database ... 285173 files and directories currently installed.) Preparing to unpack .../device-info_1.0.0_arm64.deb ... Unpacking device-info (1.0.0) ... Setting up device-info (1.0.0) ...看到Setting up这行,说明安装流程走完。然后验证:
dpkg -l | grep device-info which device-info device-infodevice-info 应该输出 Kernel、Machine、Model、CPU Temp 和 Mem 信息。Machine 显示 aarch64 还是 arm64 都可以,取决于板子内核的配置。跑通了,整个链路闭环。
6.3 卸载测试与升级流程
装完之后一定要测一次卸载,别只在开发机上自嗨:
sudo dpkg -r device-info which device-info # 应该不再输出dpkg -r 会按 dpkg 数据库里的文件清单删除文件,同时执行 postrm。如果 postrm 里写了清理逻辑,配置目录也会被清掉。测完卸载再重装一次,确认包可以重复安装,这是发布前最基本的自测。
后面要发新版本,改源码、把 Version 字段改成 1.0.1,重新 dpkg-deb 打包。旧环境上直接sudo dpkg -i device-info_1.0.1_arm64.deb,dpkg 会识别版本号并覆盖安装。
6.4 进阶:真实项目里第三方依赖怎么解决
如果工具依赖 openssl、zlib 这类库,静态链接会把所有库编进去,但现实项目里有时候不允许静态链接——比如库有安全补丁,你需要跟随系统更新。这时要处理的就不只是交叉编译工具链,还有库的交叉版本。
Debian 系给你留了官方通道:multiarch。在板子上执行:
sudo dpkg --add-architecture arm64 sudo apt-get update sudo apt-get install -y libssl-dev:arm64 zlib1g-dev:arm64然后在构建机(WSL)里交叉编译时,用--sysroot指定目标系统的库路径,或者让 apt 安装对应的:arm64库并配置头文件路径。这个课题展开讲能写一整篇,这里先把概念立住:交叉编译不只有编译器,还有目标系统的库集。动态链接的包里 Depends 写的是运行时依赖,编译机上的库版本要低于等于目标机的运行库,这个原则记住了,能少踩一半坑。
我自己跑这个流程最大的一次教训,是把项目工作目录长期放在 /mnt/c 下,Windows 侧的安全软件实时扫描加上跨文件系统 IO,打包耗时翻了三倍不止,还时不时撞上权限问题。后来把整个工作区迁到 WSL 内部、把所有构建步骤固定成一条“交叉编译 + dpkg-deb”的流水线,问题一次性消失。如果你也想把这条路走得顺一点,记住三件事:第一,所有源码和构建脚本放 WSL 文件系统,别碰 /mnt/c;第二,Architecture 写 arm64,工具链前缀是 aarch64-linux-gnu,这两个命名体系别混淆;第三,打包后用 dpkg-deb --info 和 --contents 自查一遍再传板子,别让板子当你的调试机。这三条守住了,从 Windows 到 ARM64 板子的 deb 交付,基本就是一条直线的距离。