本文是专栏《嵌入式Linux从裸板到产品》第 1 篇。全系列 15 篇,围绕一台四足机器狗的主控系统,从工具链一路写到量产 OTA。每篇都有完整可编译的代码,本文代码已在 Ubuntu 24.04 + GCC 13.3 交叉编译,并用 QEMU 8.2 实际运行验证。
0. 写在前面:这个专栏要做什么
市面上的嵌入式 Linux 教程大多有两个问题:要么是"手册搬运",把 make menuconfig 的截图贴一遍;要么只讲单点,读完驱动篇还是不知道驱动在整个系统里处于什么位置。
这个专栏换一种写法:拿一台四足机器狗的主控板当靶子,从一块裸板开始,一篇解决一个真实问题,最后得到一个能量产的系统。
0.1 贯穿全系列的案例:机器狗主控
| 子系统 | 硬件 | 接口 | 在哪一篇实现 |
|---|---|---|---|
| 主控 SoC | RK3568(4× Cortex-A55 @ 2.0GHz) | --- | 第 1~5 篇 |
| 躯干姿态 | 6 轴 IMU | I2C | 第 9 篇 |
| 足端力传感 | 4 路 ADC | SPI + IIO | 第 10 篇 |
| 急停 / 足端触地 | 按钮 + 4 路微动开关 | GPIO 中断 | 第 8 篇 |
| 遥控接收 | SBUS 接收机 | UART | 第 11 篇 |
| 电池管理 | BMS 板 | UART | 第 11 篇 |
| 云台 / 状态灯 | 舵机 + LED | PWM | 第 12 篇 |
| 12 个关节电机 | 一体化关节模组 | CAN | 第 14 篇 |
| 控制环 | 1kHz 关节闭环 | PREEMPT_RT | 第 13 篇 |
为什么选 RK3568:国产、资料多、板子便宜(核心板百元级),而且 4 核 A55 跑 1kHz 控制环绰绰有余。没有板子也没关系 :每篇的代码都会给出 QEMU 的运行方式,第 2 篇起会用 qemu-system-aarch64 模拟整机启动。
0.2 读者画像
- 会 C 语言,用过 Linux 命令行;
- 写过单片机或者 Linux 应用,但没碰过驱动、没自己做过系统;
- 想知道一块板子从上电到跑起业务程序,中间每一层到底发生了什么。
1. 四个上板翻车现场
在讲任何理论之前,先看四个真实场景。你可能已经遇到过其中一两个:
现场一 :在 PC 上交叉编译好程序,scp 到板子上一跑:
# ./dogctl-probe
-sh: ./dogctl-probe: not found
文件明明就在那里,ls 能看到,权限也是 rwx,为什么说"找不到"?
现场二:换了一块板子,报错变了:
./dogctl-probe: /lib/aarch64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by ./dogctl-probe)
代码里一个 glibc 2.34 的新函数都没用,凭什么要 2.34?
现场三:同一份代码,在 RK3568 上跑得好好的,拷到一块 RK3328 的老板子上:
Illegal instruction (core dumped)
都是 ARM64,指令集怎么会不认?
现场四:一段读串口数据的代码,在 x86 上测试完美通过,到板子上直接死循环。
这四个问题的根源各不相同:分别是动态加载器 、glibc 符号版本 、CPU 指令集扩展 和 ABI 差异 。它们有一个共同点:不理解"交叉编译到底产出了什么",就只能靠搜索引擎碰运气。本文读完,这四个问题你都能自己定位,并且能用一个脚本在上板前就提前发现。
不过在钻进工具链之前,我们得先有一张全景地图,知道这个程序最终运行在一个什么样的系统里。
2. 全景地图:嵌入式 Linux 的四大件与启动链
2.1 四大件
无论什么平台,一个能跑起来的嵌入式 Linux 系统都由四个部分组成:
| 组件 | 作用 | 典型文件 | 专栏位置 |
|---|---|---|---|
| Bootloader | 初始化 DDR、时钟,加载内核 | idbloader.img、u-boot.itb |
第 2 篇 |
| Kernel | 进程、内存、驱动、文件系统 | Image(ARM64 不压缩) |
第 3 篇 |
| Device Tree | 描述"板子上有什么硬件" | rk3568-dog.dtb |
第 4 篇 |
| Root Filesystem | init 进程、库、业务程序 | rootfs.ext4 |
第 5 篇 |
PC 上这些东西也都存在(BIOS/UEFI + GRUB、内核、ACPI 表、发行版根文件系统),只不过被发行版打包好了,你感觉不到。嵌入式 Linux 的工作,本质上就是把这四件东西亲手造一遍,并且让它们彼此对得上。
2.2 启动链的形式化描述
把启动过程抽象成一条阶段链:
S 0 → S 1 → ⋯ → S n S_0 \rightarrow S_1 \rightarrow \cdots \rightarrow S_n S0→S1→⋯→Sn
每个阶段 S i S_i Si 可以用一个四元组描述:
S i = ( 来源 i , 运行位置 i , 特权级 i , 职责 i ) S_i = (\text{来源}_i,\ \text{运行位置}_i,\ \text{特权级}_i,\ \text{职责}_i) Si=(来源i, 运行位置i, 特权级i, 职责i)
而整条链能走通,需要满足一个接力条件:
∀ i : 职责 i ⊇ 准备好 S i + 1 运行所需的全部环境 \forall i:\ \text{职责}i \supseteq \text{准备好 } S{i+1} \text{ 运行所需的全部环境} ∀i: 职责i⊇准备好 Si+1 运行所需的全部环境
这个条件听起来是废话,但几乎所有"启动卡死"的问题都是它被破坏了:比如 S i S_i Si 没初始化好 DDR, S i + 1 S_{i+1} Si+1 被加载到 DDR 里就必然跑飞;比如 U-Boot 传给内核的设备树地址不对,内核在极早期就挂掉,串口一个字都不打印。
2.3 RK3568 的实际启动链
把上面的抽象代入 RK3568(主线 U-Boot 方案):
| 阶段 | 来源 | 运行位置 | 特权级 | 职责 |
|---|---|---|---|---|
| S 0 S_0 S0 BootROM | 芯片内部掩膜 ROM | 片内 | EL3 | 按顺序探测启动介质,读取 idbloader |
| S 1 S_1 S1 TPL | eMMC/SD 扇区 64 | 片内 SRAM | EL3 | 初始化 DDR(Rockchip 闭源 blob) |
| S 2 S_2 S2 SPL | 同上,与 TPL 打包 | SRAM → DDR | EL3 | 从扇区 16384 加载 u-boot.itb(FIT 镜像) |
| S 3 S_3 S3 BL31 (TF-A) | u-boot.itb 内 |
DDR,常驻 | EL3 | 安全监控,提供 PSCI(CPU 上下线、休眠) |
| S 4 S_4 S4 U-Boot | u-boot.itb 内 |
DDR | EL2 | 加载内核 Image + DTB,设置 bootargs |
| S 5 S_5 S5 Linux | 文件系统或分区 | DDR | EL2/EL1 | 初始化驱动,挂载 rootfs |
| S 6 S_6 S6 init | rootfs 中 /sbin/init |
用户态 | EL0 | 启动业务程序,比如我们的 dogctl |
存储介质上的布局(以 512 字节扇区计):
eMMC / SD 卡
┌──────────────┬────────────────────┬──────────────────┬──────────┬───────────────┐
│ 分区表 (GPT) │ idbloader.img │ u-boot.itb │ boot 分区│ rootfs 分区 │
│ 扇区 0~63 │ 扇区 64 起 │ 扇区 16384 起 │ Image+dtb│ ext4 │
│ │ (TPL + SPL) │ (BL31+U-Boot+dtb)│ │ │
└──────────────┴────────────────────┴──────────────────┴──────────┴───────────────┘
↑ BootROM 写死的位置 ↑ SPL 配置决定
所以手工烧一张 SD 卡启动盘,前两步其实就是两条 dd:
bash
sudo dd if=idbloader.img of=/dev/sdX seek=64 conv=fsync
sudo dd if=u-boot.itb of=/dev/sdX seek=16384 conv=fsync
这里有几个值得记住的硬知识点:
- ARM 异常等级(Exception Level) :EL3 是安全监控层(TF-A 常驻于此),EL2 是虚拟化层,EL1 是操作系统内核,EL0 是用户程序。启动过程是特权级逐级下降 的过程,下降之后不能主动回去,只能通过
SMC指令"请求"上层服务。比如 Linux 关掉一个 CPU 核,实际是通过 PSCI 调用请 EL3 的 BL31 去做。 - ARM64 内核不自解压 。x86 和 ARM32 的
zImage/bzImage自带解压代码,ARM64 没有,U-Boot 加载的是原始的Image(或者由 U-Boot 解压Image.gz),用的命令是booti而不是 ARM32 的bootz。 - DDR 初始化代码是闭源的 。Rockchip、Amlogic、全志的大部分芯片,DDR 训练代码都以二进制 blob 形式提供。这是"主线 U-Boot 支持某芯片"时仍然需要
rkbin仓库的原因。
2.4 QEMU 里的启动链有什么不同
QEMU 的 virt 机型没有 BootROM,也没有 DDR 需要训练,-kernel 参数可以直接把内核加载到内存里,等于跳过了 S 0 ∼ S 4 S_0 \sim S_4 S0∼S4。这对学习驱动和应用非常方便,但也意味着启动链相关的问题在 QEMU 里看不到。所以专栏的原则是:第 2 篇的 U-Boot 会讲真板和 QEMU 两套流程,从第 6 篇开始,驱动代码在 QEMU 和真板上都能跑。
3. 开发环境拓扑
嵌入式开发是"两台机器"的协作:
宿主机 (Host) 目标板 (Target)
┌───────────────────────────┐ ┌───────────────────────┐
│ Ubuntu 24.04 x86_64 │ USB-TTL 串口 │ RK3568 机器狗主控 │
│ │ ◄────────────────► │ UART2 调试串口 │
│ 交叉工具链 │ 1500000 8N1 │ │
│ 内核/U-Boot 源码 │ │ │
│ TFTP 服务 (内核/dtb) │ 千兆网线 │ │
│ NFS 服务 (rootfs) │ ◄────────────────► │ eth0 │
│ │ │ │
│ rkdeveloptool │ USB Type-C (OTG) │ Maskrom / Loader 模式 │
│ │ ◄────────────────► │ │
└───────────────────────────┘ └───────────────────────┘
三条线各司其职:
-
串口 :唯一一条从上电第一刻就能用的通道。BootROM 之后的 TPL 就会往调试串口打印日志。注意 Rockchip 调试串口默认波特率是 1500000 ,不是常见的 115200,很多人第一次接串口看到满屏乱码就是这个原因。
bashsudo apt install picocom picocom -b 1500000 /dev/ttyUSB0 -
网线:开发阶段的效率工具。内核用 TFTP 加载、根文件系统用 NFS 挂载,改完代码不用重新烧写,重启就生效(第 2 篇细讲)。
-
USB OTG :救砖和烧写。板子变砖后按住 Maskrom 键上电,BootROM 会进入 USB 下载模式,用
rkdeveloptool重新烧写:bashrkdeveloptool ld # 列出设备,应看到 Maskrom rkdeveloptool db rk356x_spl_loader.bin # 下载临时 loader 到 DDR rkdeveloptool wl 0x40 idbloader.img # 写到扇区 64 rkdeveloptool wl 0x4000 u-boot.itb # 写到扇区 16384 rkdeveloptool rd # 复位
宿主机一次性装好本专栏要用的软件包:
bash
sudo apt update
sudo apt install -y \
build-essential cmake git bc bison flex libssl-dev libncurses-dev \
device-tree-compiler u-boot-tools python3-dev swig \
gcc-aarch64-linux-gnu g++-aarch64-linux-gnu \
qemu-user qemu-user-binfmt qemu-system-arm \
picocom tftpd-hpa nfs-kernel-server file
4. 交叉编译:原理部分
4.1 三元组(triplet)
工具链的名字 aarch64-linux-gnu-gcc 不是随便起的,前缀是一个目标三元组(虽然叫"三元组",实际常有四段):
T = ( arch , vendor , os , abi/libc ) T = (\text{arch},\ \text{vendor},\ \text{os},\ \text{abi/libc}) T=(arch, vendor, os, abi/libc)
| 三元组 | arch | vendor | os | abi/libc | 用途 |
|---|---|---|---|---|---|
aarch64-linux-gnu |
aarch64 | (省略) | linux | gnu (glibc) | 本专栏主力 |
aarch64-buildroot-linux-gnu |
aarch64 | buildroot | linux | gnu | Buildroot 生成 |
aarch64-linux-musl |
aarch64 | (省略) | linux | musl | 静态小体积程序 |
arm-linux-gnueabihf |
arm (32 位) | (省略) | linux | gnueabi + 硬浮点 | ARM32 板卡 |
aarch64-none-elf |
aarch64 | none | 无 OS | elf | 裸机 / 写 BL31 这类固件 |
最后一行很重要:none-elf 工具链没有 libc、没有系统调用 ,用来编译裸机程序。用它编译 Linux 应用程序是不行的,反过来用 linux-gnu 编译 U-Boot 之类的固件通常可以,因为固件不链接 libc。
4.2 build / host / target
GNU 构建系统用三个角色描述"在哪编、在哪跑、给谁生成代码":
- build:执行编译动作的机器;
- host :编译产物运行的机器;
- target :编译产物生成代码的目标机器(只对编译器、调试器这类工具有意义)。
| 场景 | build | host | target | 叫法 |
|---|---|---|---|---|
| PC 上编译 PC 程序 | x86_64 | x86_64 | --- | 本地编译 |
| PC 上编译机器狗程序 | x86_64 | aarch64 | --- | 交叉编译 |
| PC 上编译"能生成 ARM 代码的 gcc" | x86_64 | x86_64 | aarch64 | 交叉工具链 |
| PC 上编译"跑在板子上、生成 ARM 代码的 gcc" | x86_64 | aarch64 | aarch64 | 交叉-本地 |
| 三者都不同 | x86_64 | aarch64 | riscv64 | Canadian Cross |
这张表会在第 5 篇用 autotools 交叉编译第三方库时直接用上:./configure --host=aarch64-linux-gnu 里的 --host,指的就是产物运行的机器,而不是你现在敲命令的这台。这是新手最容易弄反的地方。
4.3 一个交叉工具链由什么组成
aarch64-linux-gnu 工具链
├── binutils 汇编器 as、链接器 ld、readelf、objdump、strip ...
├── gcc C/C++ 编译器 + libgcc(编译器运行时,如 64 位除法、outline atomics)
├── C 库 glibc / musl:printf、malloc、pthread、系统调用封装
├── 内核头文件 linux/*.h、asm/*.h:系统调用号、ioctl 定义、结构体
└── sysroot 以上头文件和库按目标板的目录结构存放的"影子根目录"
其中 sysroot 是理解交叉编译的关键。它是目标板根文件系统的一个"开发版镜像":目标板上的 /usr/lib/libc.so.6,在宿主机上对应 <sysroot>/usr/lib/libc.so.6。编译器找头文件、链接器找库,都应该只 在 sysroot 里找,绝不能找到宿主机的 /usr/lib 里去。
可以直接问 gcc 它在哪里找东西:
bash
$ echo | aarch64-linux-gnu-gcc -E -v - 2>&1 | sed -n '/search starts here/,/End of search/p'
#include <...> search starts here:
/usr/lib/gcc-cross/aarch64-linux-gnu/13/include
/usr/lib/gcc-cross/aarch64-linux-gnu/13/../../../../aarch64-linux-gnu/include
/usr/include
End of search list.
$ aarch64-linux-gnu-gcc -print-file-name=libc.so
/usr/lib/gcc-cross/aarch64-linux-gnu/13/../../../../aarch64-linux-gnu/lib/../lib/libc.so
$ aarch64-linux-gnu-gcc -print-sysroot
/
注意第三条搜索路径:/usr/include,宿主机的头文件目录 。这是 Ubuntu/Debian 发行版工具链的特点:它依赖 multiarch 机制,把体系结构相关的头文件放在 /usr/include/aarch64-linux-gnu/,体系结构无关的头文件和宿主机共用 /usr/include。-print-sysroot 打印出 / 也说明了这一点。
这对学习来说足够方便,但在产品项目里是隐患:如果你在宿主机上 apt install libfoo-dev,交叉编译时可能"意外地"找到这个 x86 版本的头文件,头文件版本和目标板上库的版本对不上,就会出现诡异的运行时错误。这也是为什么产品项目要用 Buildroot 或厂商 SDK 生成的、sysroot 完全隔离的工具链(第 5 篇)。
4.4 工具链从哪来
| 来源 | 获取方式 | glibc 版本 | sysroot 隔离 | 适用场景 |
|---|---|---|---|---|
| 发行版包 | apt install gcc-aarch64-linux-gnu |
跟随宿主机(24.04 是 2.39) | 否(multiarch) | 学习、编译内核/U-Boot |
| Arm 官方 | developer.arm.com 下载 | 固定版本 | 是 | 编译内核/U-Boot、裸机 |
| 厂商 SDK | Rockchip SDK prebuilts/gcc |
与 SDK 的 rootfs 匹配 | 是 | 跟着厂商 BSP 走 |
| Buildroot / Yocto 生成 | 构建系统自动生成 | 与你的 rootfs 完全一致 | 是 | 产品 |
一条朴素但重要的原则:
编译内核和 U-Boot 时,工具链版本差一点没关系,因为它们不链接 libc;编译用户态程序时,工具链的 libc 必须和目标板 rootfs 里的 libc 匹配。
现场二的问题,就是违反了这条原则。
5. 实战:机器狗主控板"体检程序"
新板子到手、新工具链装好以后,第一件事不是写业务,而是验证"编译出来的东西"和"板子"对得上 。我们写一个 dogctl-probe 程序,它不碰任何硬件,只回答这些问题:
- 编译期的架构、编译器、glibc 版本是什么?运行时的又是什么?
- 字节序、
char符号性、结构体布局是什么样的? - CPU 支持哪些扩展指令?
- 系统定时精度够不够跑 1kHz 控制环?
5.1 工程结构
dogctl/
├── CMakeLists.txt
├── Makefile # 不用 CMake 时的备选
├── README.md
├── cmake/
│ └── aarch64-linux-gnu.cmake # 交叉编译工具链文件
├── scripts/
│ └── check_elf.sh # 上板前检查
└── src/
├── main.c # 体检程序
├── joint_proto.h # 关节指令帧定义(第 14 篇复用)
├── pitfall_char.c # 反面教材:char 符号性
└── atomic_demo.c # 反面教材:-mcpu 与 LSE 原子指令
5.2 关节指令帧:先把协议结构体定下来
机器狗的 12 个关节电机走 CAN 总线,经典 CAN 一帧数据最多 8 字节。指令帧结构体从第 1 篇就定下来,因为它最能暴露 ABI 问题:
c
/* joint_proto.h - 机器狗关节指令帧(第14篇 SocketCAN 会继续使用) */
#ifndef JOINT_PROTO_H
#define JOINT_PROTO_H
#include <stdint.h>
#include <assert.h>
/* 错误示范:依赖编译器默认对齐,布局随 ABI 变化 */
struct joint_cmd_naive {
uint8_t joint_id; /* 0..11,四条腿各 3 个关节 */
int32_t pos_mrad; /* 目标位置,毫弧度 */
uint8_t mode; /* 0=空闲 1=位置 2=力矩 */
int16_t torque_mNm; /* 前馈力矩,毫牛米 */
};
/* 正确做法:显式打包 + 固定宽度类型 + 编译期断言 */
struct __attribute__((packed)) joint_cmd {
uint8_t joint_id;
uint8_t mode;
int16_t torque_mNm; /* 小端 */
int32_t pos_mrad; /* 小端 */
};
static_assert(sizeof(struct joint_cmd) == 8, "joint_cmd must fit one CAN frame");
#endif
static_assert 是 C11 的编译期断言。一旦有人改了结构体导致长度不是 8,编译直接失败,而不是等到电机收到错位的数据乱转。对于要上机器人的代码,能在编译期发现的错误,就不要留到运行期。
5.3 体检程序主体
c
/*
* dogctl-probe: 四足机器狗主控板"体检"程序
* 新板子到手、新工具链装好后,第一个跑的程序。
* 它不控制任何硬件,只回答一个问题:编译出来的东西和板子对得上吗?
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#include <limits.h>
#include <time.h>
#include <unistd.h>
#include <sys/utsname.h>
#include <sys/auxv.h>
#include <gnu/libc-version.h>
#include "joint_proto.h"
#if defined(__aarch64__)
# include <asm/hwcap.h>
# define BUILD_ARCH "aarch64"
#elif defined(__x86_64__)
# define BUILD_ARCH "x86_64"
#else
# define BUILD_ARCH "unknown"
#endif
#ifndef DOGCTL_VERSION
# define DOGCTL_VERSION "dev"
#endif
static void section(const char *title)
{
printf("\n== %s ==\n", title);
}
static void probe_build(void)
{
section("构建信息(编译期确定)");
printf("dogctl 版本 : %s\n", DOGCTL_VERSION);
printf("目标架构 : %s\n", BUILD_ARCH);
printf("编译器 : %s\n", __VERSION__);
printf("编译时 glibc : %d.%d\n", __GLIBC__, __GLIBC_MINOR__);
}
static void probe_runtime(void)
{
struct utsname u;
section("运行环境(运行期读取)");
if (uname(&u) == 0) {
printf("内核 : %s %s\n", u.sysname, u.release);
printf("机器 : %s\n", u.machine);
}
printf("运行时 glibc : %s\n", gnu_get_libc_version());
printf("在线 CPU 核数 : %ld\n", sysconf(_SC_NPROCESSORS_ONLN));
printf("页大小 : %ld 字节\n", sysconf(_SC_PAGESIZE));
/* 设备树里的板卡型号,真板上才有(第4篇详解) */
FILE *fp = fopen("/proc/device-tree/model", "r");
if (fp) {
char model[128] = {0};
if (fgets(model, sizeof(model), fp))
printf("板卡型号 : %s\n", model);
fclose(fp);
} else {
printf("板卡型号 : (无设备树,可能运行在 QEMU 用户态)\n");
}
}
static void probe_abi(void)
{
section("ABI 关键差异");
/* 1. 字节序 */
uint16_t x = 0x0102;
printf("字节序 : %s\n",
*(uint8_t *)&x == 0x02 ? "小端 (little-endian)" : "大端 (big-endian)");
/* 2. char 的符号性:x86 默认 signed,ARM 默认 unsigned */
printf("char 是否有符号 : %s ((char)0xFF = %d)\n",
(CHAR_MIN < 0) ? "signed" : "unsigned", (int)(char)0xFF);
/* 3. 基本类型宽度 */
printf("sizeof(long) : %zu, sizeof(void*) : %zu\n",
sizeof(long), sizeof(void *));
/* 4. 结构体布局 */
printf("joint_cmd_naive : sizeof=%zu, offsetof(pos)=%zu, offsetof(torque)=%zu\n",
sizeof(struct joint_cmd_naive),
offsetof(struct joint_cmd_naive, pos_mrad),
offsetof(struct joint_cmd_naive, torque_mNm));
printf("joint_cmd(packed): sizeof=%zu <- 可直接塞进 8 字节 CAN 帧\n",
sizeof(struct joint_cmd));
}
static void probe_cpu_features(void)
{
section("CPU 特性(来自内核传给进程的 auxv)");
#if defined(__aarch64__)
unsigned long hw = getauxval(AT_HWCAP);
struct { unsigned long bit; const char *name; const char *why; } f[] = {
{ HWCAP_FP, "fp", "硬件浮点,姿态解算必需" },
{ HWCAP_ASIMD, "asimd", "NEON SIMD,矩阵运算加速" },
{ HWCAP_CRC32, "crc32", "CRC 指令,校验通信帧" },
{ HWCAP_ATOMICS, "atomics", "LSE 原子指令,无锁队列更快" },
};
for (size_t i = 0; i < sizeof(f) / sizeof(f[0]); i++)
printf("%-8s : %-3s %s\n", f[i].name,
(hw & f[i].bit) ? "有" : "无", f[i].why);
#else
printf("(非 aarch64 构建,跳过)\n");
#endif
}
static void probe_timing(void)
{
struct timespec res, t0, t1;
section("时间基准(为第13篇 1kHz 控制环做准备)");
clock_getres(CLOCK_MONOTONIC, &res);
printf("CLOCK_MONOTONIC 分辨率 : %ld ns\n", res.tv_nsec);
/* 粗测一次 1ms 睡眠的实际耗时 */
struct timespec req = { .tv_sec = 0, .tv_nsec = 1000000 };
long worst = 0;
for (int i = 0; i < 100; i++) {
clock_gettime(CLOCK_MONOTONIC, &t0);
nanosleep(&req, NULL);
clock_gettime(CLOCK_MONOTONIC, &t1);
long ns = (t1.tv_sec - t0.tv_sec) * 1000000000L + (t1.tv_nsec - t0.tv_nsec);
long over = ns - 1000000;
if (over > worst)
worst = over;
}
printf("nanosleep(1ms) x100 最大超时 : %ld us\n", worst / 1000);
}
int main(void)
{
printf("dogctl-probe: 四足机器狗主控板体检\n");
probe_build();
probe_runtime();
probe_abi();
probe_cpu_features();
probe_timing();
return 0;
}
几个值得展开的点:
- 编译期信息 vs 运行期信息 。
__GLIBC__/__GLIBC_MINOR__是编译时头文件里的宏,gnu_get_libc_version()是运行时真正加载的 libc 返回的。两者不一致时,就是现场二的前兆。 getauxval(AT_HWCAP)。内核在启动进程时,会把一组"辅助向量"(auxv)压在进程栈上,其中AT_HWCAP是一个位图,每一位代表一个 CPU 特性。这是用户态程序判断 CPU 能力最可靠 的方式,比解析/proc/cpuinfo快也比它准。- 定时测试 。1kHz 控制环意味着每个周期 1ms,如果一次
nanosleep(1ms)动不动超时几百微秒,控制环就会抖。这里先建立一个基线,第 13 篇换上 PREEMPT_RT 内核以后再对比。
5.4 CMake 工具链文件
cmake
# cmake/aarch64-linux-gnu.cmake
# 用法: cmake -B build-arm -DCMAKE_TOOLCHAIN_FILE=cmake/aarch64-linux-gnu.cmake
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
# 工具链前缀,可在命令行用 -DCROSS_PREFIX=... 覆盖(例如厂商 SDK 里的工具链)
if(NOT DEFINED CROSS_PREFIX)
set(CROSS_PREFIX aarch64-linux-gnu-)
endif()
set(CMAKE_C_COMPILER ${CROSS_PREFIX}gcc)
set(CMAKE_CXX_COMPILER ${CROSS_PREFIX}g++)
# 针对目标 CPU 调优。RK3568 = 4x Cortex-A55;RK3588 = 4x A76 + 4x A55
# 注意:-mcpu 会放开该 CPU 支持的全部指令,二进制不能再拿到更老的核上跑
set(DOG_CPU "cortex-a55" CACHE STRING "目标 CPU,传给 -mcpu")
set(CMAKE_C_FLAGS_INIT "-mcpu=${DOG_CPU}")
set(CMAKE_CXX_FLAGS_INIT "-mcpu=${DOG_CPU}")
# sysroot:目标板根文件系统的"镜像",头文件和库都从这里找
# 发行版工具链自带的 sysroot 在 /usr/aarch64-linux-gnu,留空即可
# 用 Buildroot / 厂商 SDK 时设成 output/host/aarch64-buildroot-linux-gnu/sysroot 之类
if(DEFINED ENV{DOG_SYSROOT})
set(CMAKE_SYSROOT $ENV{DOG_SYSROOT})
endif()
# 关键四行:程序(工具)在宿主机找,库/头文件/包只在目标 sysroot 里找
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
# 让 find_package / pkg-config 不会误用宿主机的 x86 库
if(CMAKE_SYSROOT)
set(ENV{PKG_CONFIG_SYSROOT_DIR} ${CMAKE_SYSROOT})
set(ENV{PKG_CONFIG_LIBDIR}
"${CMAKE_SYSROOT}/usr/lib/pkgconfig:${CMAKE_SYSROOT}/usr/share/pkgconfig")
endif()
# 在宿主机上用 QEMU 跑交叉编译出的测试(ctest)
find_program(QEMU_AARCH64 qemu-aarch64)
if(QEMU_AARCH64)
set(CMAKE_CROSSCOMPILING_EMULATOR
${QEMU_AARCH64} -L /usr/aarch64-linux-gnu)
endif()
逐段解释:
CMAKE_SYSTEM_NAME:一旦设置,CMake 就认为当前是交叉编译,CMAKE_CROSSCOMPILING变为真。-mcpu=cortex-a55:让编译器针对 A55 调度指令,并且允许使用 A55 支持的全部扩展指令。后半句是现场三的根源,第 7 节细讲。- 四个
FIND_ROOT_PATH_MODE:这是工具链文件里最重要的四行。PROGRAM NEVER的意思是,像protoc、python这类构建时要在宿主机上执行 的程序,到宿主机找;LIBRARY/INCLUDE/PACKAGE ONLY的意思是,库、头文件、CMake 包配置只 到 sysroot 里找。少了这几行,find_library(m)就可能找到/usr/lib/x86_64-linux-gnu/libm.so,链接时才报"file in wrong format"。 - pkg-config 的两个环境变量 :
PKG_CONFIG_LIBDIR替换(而不是追加)搜索路径,PKG_CONFIG_SYSROOT_DIR给.pc文件里的-I/usr/include自动加上 sysroot 前缀。第 5 篇引入第三方库时会用到。 CMAKE_CROSSCOMPILING_EMULATOR:指定一个模拟器,ctest和add_custom_command运行目标程序时会自动套上它。这样交叉编译的单元测试可以直接在 CI 上跑,不需要板子。
CMakeLists.txt 本身反而不需要关心交叉编译,这正是工具链文件的设计意图:工程描述"编什么",工具链文件描述"用什么编",两者解耦。
cmake
cmake_minimum_required(VERSION 3.16)
project(dogctl VERSION 0.1.0 LANGUAGES C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
option(DOGCTL_STATIC "静态链接(不依赖目标板 glibc 版本)" OFF)
add_executable(dogctl-probe src/main.c)
target_compile_options(dogctl-probe PRIVATE -Wall -Wextra -O2)
target_compile_definitions(dogctl-probe PRIVATE
DOGCTL_VERSION="${PROJECT_VERSION}")
if(DOGCTL_STATIC)
target_link_options(dogctl-probe PRIVATE -static)
endif()
enable_testing()
add_test(NAME probe-runs COMMAND dogctl-probe)
# 第1篇的两个反面教材
add_executable(pitfall-char src/pitfall_char.c)
add_executable(atomic-demo src/atomic_demo.c)
5.5 不用 CMake:沿用内核的 CROSS_COMPILE 约定
内核、U-Boot、BusyBox 都用同一个约定:CROSS_COMPILE 变量是工具链前缀,$(CROSS_COMPILE)gcc 就是编译器。自己的 Makefile 遵守同样的约定,以后接入 Buildroot 会省很多事:
makefile
# 不用 CMake 时的最小 Makefile,沿用内核/U-Boot 的 CROSS_COMPILE 约定
# make -> 本机 x86 编译
# make CROSS_COMPILE=aarch64-linux-gnu- -> 交叉编译
# make CROSS_COMPILE=aarch64-linux-gnu- STATIC=1
CROSS_COMPILE ?=
CC := $(CROSS_COMPILE)gcc
STRIP := $(CROSS_COMPILE)strip
CFLAGS ?= -O2 -Wall -Wextra
LDFLAGS ?=
ifeq ($(STATIC),1)
LDFLAGS += -static
endif
TARGET := dogctl-probe
$(TARGET): src/main.c src/joint_proto.h
$(CC) $(CFLAGS) -DDOGCTL_VERSION=\"0.1.0\" -o $@ src/main.c $(LDFLAGS)
strip: $(TARGET)
$(STRIP) $(TARGET)
clean:
rm -f $(TARGET)
.PHONY: strip clean
6. 编译与运行
6.1 交叉编译
bash
$ cmake -B build-arm -DCMAKE_TOOLCHAIN_FILE=cmake/aarch64-linux-gnu.cmake -DCMAKE_BUILD_TYPE=Release
$ cmake --build build-arm
[ 33%] Built target dogctl-probe
[ 66%] Built target pitfall-char
[100%] Built target atomic-demo
$ file build-arm/dogctl-probe
build-arm/dogctl-probe: ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV),
dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, not stripped
file 的输出里已经藏着两个关键信息:ARM aarch64 说明架构对了,interpreter /lib/ld-linux-aarch64.so.1 说明它需要一个动态加载器。记住这个路径,现场一就和它有关。
6.2 在 x86 上直接运行 ARM 程序:QEMU 用户态模拟
QEMU 有两种模式:系统模式 (qemu-system-aarch64)模拟整台机器,要跑完整的内核;用户模式 (qemu-aarch64)只模拟 CPU 指令,系统调用直接转给宿主机内核。后者启动是毫秒级的,非常适合测试应用程序:
bash
$ qemu-aarch64 -L /usr/aarch64-linux-gnu build-arm/dogctl-probe
-L 指定动态加载器和共享库的根目录,相当于告诉 QEMU "sysroot 在这里",也可以用环境变量 QEMU_LD_PREFIX 代替。实际输出:
dogctl-probe: 四足机器狗主控板体检
== 构建信息(编译期确定) ==
dogctl 版本 : 0.1.0
目标架构 : aarch64
编译器 : 13.3.0
编译时 glibc : 2.39
== 运行环境(运行期读取) ==
内核 : Linux 6.18.44
机器 : aarch64
运行时 glibc : 2.39
在线 CPU 核数 : 2
页大小 : 4096 字节
板卡型号 : (无设备树,可能运行在 QEMU 用户态)
== ABI 关键差异 ==
字节序 : 小端 (little-endian)
char 是否有符号 : unsigned ((char)0xFF = 255)
sizeof(long) : 8, sizeof(void*) : 8
joint_cmd_naive : sizeof=12, offsetof(pos)=4, offsetof(torque)=10
joint_cmd(packed): sizeof=8 <- 可直接塞进 8 字节 CAN 帧
== CPU 特性(来自内核传给进程的 auxv) ==
fp : 有 硬件浮点,姿态解算必需
asimd : 有 NEON SIMD,矩阵运算加速
crc32 : 有 CRC 指令,校验通信帧
atomics : 有 LSE 原子指令,无锁队列更快
== 时间基准(为第13篇 1kHz 控制环做准备) ==
CLOCK_MONOTONIC 分辨率 : 1 ns
nanosleep(1ms) x100 最大超时 : 296 us
注意"内核"那一行显示的是宿主机 的内核版本,"在线 CPU 核数"也是宿主机分给 QEMU 的核数,因为用户态模拟把系统调用直接交给了宿主机内核。"机器"显示 aarch64 则是 QEMU 拦截 uname 后改写的结果。所以 QEMU 用户态适合验证指令和 ABI ,不能拿来测量系统行为,定时数据必须在真板上测。
交叉编译的测试也可以直接跑:
bash
$ cd build-arm && ctest
100% tests passed, 0 tests failed out of 1
6.3 让 ARM 程序像本地程序一样运行:binfmt_misc
装了 qemu-user-binfmt 以后,可以直接 ./build-arm/dogctl-probe 运行,不用敲 qemu-aarch64。原理是内核的 binfmt_misc 机制:内核执行一个文件时,会用文件头的魔数去匹配一张注册表,匹配到 ARM64 ELF 的魔数,就转交给注册的解释器(qemu-aarch64)去执行。
Ubuntu 的 qemu-user-binfmt 包把注册规则放在 /usr/lib/binfmt.d/qemu-aarch64.conf,开机时由 systemd 写进 /proc/sys/fs/binfmt_misc/register:
:qemu-aarch64:M::\x7f\x45\x4c\x46\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\xb7\x00:\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff:/usr/libexec/qemu-binfmt/aarch64-binfmt-P:OP
格式是 :名字:类型:偏移:魔数:掩码:解释器:标志,对照 ELF 头逐字节读:
| 魔数字节 | ELF 字段 | 含义 |
|---|---|---|
7f 45 4c 46 |
e_ident[0..3] |
\x7fELF |
02 |
EI_CLASS |
ELFCLASS64,64 位 |
01 |
EI_DATA |
小端 |
02 00 |
e_type |
ET_EXEC;掩码 fe 屏蔽最低位,所以 ET_DYN(3,PIE)也能匹配 |
b7 00 |
e_machine |
0xB7 = 183 = EM_AARCH64 |
这个机制也是 Docker --platform linux/arm64 能在 x86 上跑 ARM 镜像的原理。
6.4 上板
bash
# 板子已经联网时
scp build-arm/dogctl-probe root@192.168.1.100:/tmp/
ssh root@192.168.1.100 /tmp/dogctl-probe
如果板子还没有网络,就用 U 盘或者串口的 rz 传。第 2 篇搭好 NFS 以后,宿主机编译完的文件板子上直接可见,就再也不用传了。
7. 上板前体检:把四个翻车现场消灭在宿主机上
7.1 用 readelf 读懂一个 ELF
readelf 是嵌入式开发者最该熟悉的工具,一定要用交叉版本 (aarch64-linux-gnu-readelf),宿主机的 readelf 虽然也能读,但 objdump -d 反汇编时就不认 ARM 指令了。
ELF 头:
bash
$ aarch64-linux-gnu-readelf -h build-arm/dogctl-probe
Class: ELF64
Data: 2's complement, little endian
Type: DYN (Position-Independent Executable file)
Machine: AArch64
Entry point address: 0xe40
Type: DYN 说明这是一个位置无关可执行文件(PIE) ,加载地址由内核随机决定(ASLR),所以入口地址是一个相对偏移 0xe40。
程序头(Program Headers),即内核和加载器真正关心的"段":
Type Offset VirtAddr FileSiz Flags Align
PHDR 0x0000000000000040 0x0000000000000040 0x00000000000001f8 R 0x8
INTERP 0x0000000000000238 0x0000000000000238 0x000000000000001b R 0x1
[Requesting program interpreter: /lib/ld-linux-aarch64.so.1]
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000001798 R E 0x10000
LOAD 0x000000000000fd18 0x000000000001fd18 0x0000000000000358 RW 0x10000
DYNAMIC 0x000000000000fd28 0x000000000001fd28 0x0000000000000200 RW 0x8
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 RW 0x10
GNU_RELRO 0x000000000000fd18 0x000000000001fd18 0x00000000000002e8 R 0x1
(为了排版,省略了 PhysAddr、MemSiz 两列和 NOTE、GNU_EH_FRAME 两行。)
INTERP:动态加载器的路径,写死在文件里。- 两个
LOAD:一个只读可执行(代码段),一个可读写(数据段)。Align 0x10000是 64KB 对齐。ARM64 的内核可能用 4KB、16KB 或 64KB 页,64KB 对齐保证三种页大小的内核都能加载它。 GNU_STACK的 Flags 是RW没有E:栈不可执行,这是基本的安全加固。
7.2 现场一:not found,其实是加载器找不到
内核执行一个动态链接的程序时,流程是这样的:
execve("./dogctl-probe")
└─ 内核读 ELF 头,发现有 PT_INTERP 段
└─ 内核先加载 /lib/ld-linux-aarch64.so.1 ← 如果这个文件不存在,execve 返回 ENOENT
└─ ld.so 加载 libc.so.6 等依赖库,做重定位
└─ 跳转到程序入口 _start → __libc_start_main → main
execve 返回 ENOENT(No such file or directory),shell 就报"not found"。但不存在的不是你的程序,而是加载器。
什么情况下板子上会没有 /lib/ld-linux-aarch64.so.1?最常见的两种:
- 根文件系统用的是 musl (加载器叫
/lib/ld-musl-aarch64.so.1),而程序是用 glibc 工具链编的; - 根文件系统是 BusyBox 静态编译的最小系统,里面根本没有任何共享库。
在宿主机上可以直接复现:不给 QEMU 指定 sysroot,它就去宿主机的 /lib 下找 ARM64 加载器,自然找不到:
bash
$ qemu-aarch64 build-arm/dogctl-probe
qemu-aarch64: Could not open '/lib/ld-linux-aarch64.so.1': No such file or directory
解决方法:要么换匹配 rootfs 的工具链,要么静态链接(见第 8 节)。
7.3 现场二:GLIBC_2.34 not found
动态链接的程序对 glibc 的依赖精确到符号版本。glibc 用符号版本化(symbol versioning)实现向后兼容:同一个函数可以有多个版本并存,旧程序链接旧版本,新程序链接新版本。查看程序依赖的符号版本:
bash
$ aarch64-linux-gnu-objdump -T build-arm/dogctl-probe | grep GLIBC_2.34
0000000000000000 DF *UND* 0000000000000000 (GLIBC_2.34) __libc_start_main
罪魁祸首是 __libc_start_main。这不是你调用的函数,而是 C 运行时启动代码(crt1.o 里的 _start)调用的,每个程序都会用到它 。glibc 2.34 把 libpthread 合并进了 libc,同时引入了新版本的 __libc_start_main@GLIBC_2.34。结论很直接:
用 glibc ≥ 2.34 的工具链编译出来的任何动态链接程序,哪怕只是一个 hello world,都无法在 glibc < 2.34 的系统上运行。
几个常见发行版的 glibc 版本:Ubuntu 20.04 和 Debian 11 是 2.31,Ubuntu 22.04 是 2.35,Debian 12 是 2.36,Ubuntu 24.04 是 2.39。很多厂商 SDK 默认的 Debian 11 rootfs 正好卡在 2.31,这就是为什么用 Ubuntu 24.04 的发行版工具链编译、拷到板子上就报错。
glibc 只保证向后兼容 (旧程序能在新 glibc 上跑),不保证向前兼容。所以解决方法只有两个:
- 用不高于目标板 glibc 版本的工具链编译(Buildroot 或厂商 SDK);
- 静态链接。
7.4 自动化:check_elf.sh
每次都手敲 readelf 太累,把上面的检查写成一个脚本,以后集成进 CI:
bash
#!/bin/sh
# check_elf.sh - 上板前检查交叉编译产物
# 用法: ./scripts/check_elf.sh <elf文件> [目标板glibc版本,如 2.31]
set -e
ELF=$1
TARGET_GLIBC=${2:-}
READELF=${READELF:-aarch64-linux-gnu-readelf}
OBJDUMP=${OBJDUMP:-aarch64-linux-gnu-objdump}
[ -f "$ELF" ] || { echo "用法: $0 <elf> [目标glibc版本]"; exit 1; }
echo "== 1. 架构 =="
MACHINE=$($READELF -h "$ELF" | awk -F: '/Machine/ {gsub(/^ +/,"",$2); print $2}')
CLASS=$($READELF -h "$ELF" | awk -F: '/Class/ {gsub(/^ +/,"",$2); print $2}')
echo "Machine: $MACHINE ($CLASS)"
case "$MACHINE" in
*AArch64*) echo " OK: ARM64 可执行文件" ;;
*) echo " 错误: 不是 AArch64,检查是否用错了编译器"; exit 2 ;;
esac
echo "== 2. 动态加载器 (PT_INTERP) =="
INTERP=$($READELF -l "$ELF" | sed -n 's/.*Requesting program interpreter: \(.*\)]/\1/p')
if [ -z "$INTERP" ]; then
echo " 静态链接,无需加载器"
else
echo " $INTERP <- 目标板上必须存在此文件"
fi
echo "== 3. 依赖的共享库 (DT_NEEDED) =="
$READELF -d "$ELF" | awk '/NEEDED/ {print " " $NF}' | tr -d '[]'
RPATH=$($READELF -d "$ELF" | awk '/RPATH|RUNPATH/ {print $NF}')
[ -n "$RPATH" ] && echo " RPATH/RUNPATH: $RPATH"
echo "== 4. 所需最高 glibc 符号版本 =="
NEED=$($OBJDUMP -T "$ELF" 2>/dev/null | grep -o 'GLIBC_[0-9.]*' \
| sed 's/GLIBC_//' | sort -t. -k1,1n -k2,2n -u | tail -1)
if [ -z "$NEED" ]; then
echo " 无 glibc 动态符号(静态链接或非 glibc)"
else
echo " 需要 GLIBC_$NEED"
if [ -n "$TARGET_GLIBC" ]; then
HIGHER=$(printf '%s\n%s\n' "$NEED" "$TARGET_GLIBC" \
| sort -t. -k1,1n -k2,2n | tail -1)
if [ "$HIGHER" = "$TARGET_GLIBC" ]; then
echo " OK: 目标板 glibc $TARGET_GLIBC 满足要求"
else
echo " 错误: 目标板 glibc $TARGET_GLIBC < $NEED,上板会报 'GLIBC_$NEED not found'"
exit 3
fi
fi
fi
假设目标板是 Debian 11(glibc 2.31),运行结果:
$ ./scripts/check_elf.sh build-arm/dogctl-probe 2.31
== 1. 架构 ==
Machine: AArch64 (ELF64)
OK: ARM64 可执行文件
== 2. 动态加载器 (PT_INTERP) ==
/lib/ld-linux-aarch64.so.1 <- 目标板上必须存在此文件
== 3. 依赖的共享库 (DT_NEEDED) ==
libc.so.6
ld-linux-aarch64.so.1
== 4. 所需最高 glibc 符号版本 ==
需要 GLIBC_2.34
错误: 目标板 glibc 2.31 < 2.34,上板会报 'GLIBC_2.34 not found'
脚本返回码为 3,在 CI 里会直接让流水线失败。再看两个对照:
$ ./scripts/check_elf.sh build-x86/dogctl-probe # 误用了本地编译的产物
== 1. 架构 ==
Machine: Advanced Micro Devices X86-64 (ELF64)
错误: 不是 AArch64,检查是否用错了编译器
$ ./scripts/check_elf.sh dogctl-probe-static # make STATIC=1 的产物,改了个名
== 1. 架构 ==
Machine: AArch64 (ELF64)
OK: ARM64 可执行文件
== 2. 动态加载器 (PT_INTERP) ==
静态链接,无需加载器
== 3. 依赖的共享库 (DT_NEEDED) ==
== 4. 所需最高 glibc 符号版本 ==
无 glibc 动态符号(静态链接或非 glibc)
目标板的 glibc 版本怎么查:在板子上执行
ldd --version,或者直接执行 libc 本身(Debian 系路径是/lib/aarch64-linux-gnu/libc.so.6),glibc 的 libc.so.6 是可以当程序运行的,会打印版本信息。
7.5 现场三:Illegal instruction 和 -mcpu
都是 ARMv8,指令集为什么会不认?因为 ARMv8 不是一个固定的指令集,而是一个基线加一堆可选扩展:
| CPU | 架构版本 | LSE 原子指令 | 点积 dotprod | 典型芯片 |
|---|---|---|---|---|
| Cortex-A53 | ARMv8.0-A | 无 | 无 | RK3328、RK3399 小核、全志 H6 |
| Cortex-A55 | ARMv8.2-A | 有 | 有 | RK3568、RK3588 小核 |
| Cortex-A76 | ARMv8.2-A | 有 | 有 | RK3588 大核 |
LSE(Large System Extensions)是 ARMv8.1 引入的原子指令,用一条 ldadd 代替原来 ldxr/stxr 的循环重试,在多核竞争时性能好很多。问题在于,一旦用 -mcpu=cortex-a55 编译,GCC 就会直接生成 LSE 指令 。用 atomic_demo.c 复现:
c
/*
* atomic_demo.c - -mcpu 选错导致 Illegal instruction 的复现
* 默认编译 : 走 outline-atomics,运行时按 HWCAP 选择指令,A53/A55 都能跑
* -mcpu=cortex-a55: 直接内联 LSE 指令 ldaddal,拿到 A53 上会 SIGILL
*/
#include <stdio.h>
#include <stdatomic.h>
static atomic_int tick_count; /* 第13篇控制环里会用它统计周期数 */
int main(void)
{
for (int i = 0; i < 1000; i++)
atomic_fetch_add(&tick_count, 1);
printf("tick_count = %d\n", atomic_load(&tick_count));
return 0;
}
bash
# 工具链文件默认 -mcpu=cortex-a55,反汇编能看到 LSE 指令
$ aarch64-linux-gnu-objdump -d build-arm/atomic-demo | grep ldadd
698: b8e20023 ldaddal w2, w3, [x1]
# QEMU 可以用 -cpu 模拟不同的核
$ qemu-aarch64 -cpu cortex-a55 -L /usr/aarch64-linux-gnu build-arm/atomic-demo
tick_count = 1000
$ qemu-aarch64 -cpu cortex-a53 -L /usr/aarch64-linux-gnu build-arm/atomic-demo
qemu: uncaught target signal 4 (Illegal instruction) - core dumped
如果不加 -mcpu ,GCC 默认使用 outline atomics :原子操作被编译成对 libgcc 中辅助函数的调用,辅助函数在运行时检查 AT_HWCAP 里的 atomics 位,有 LSE 就用 LSE,没有就退回 ldxr/stxr:
bash
$ aarch64-linux-gnu-gcc -O2 src/atomic_demo.c -o atomic-default
$ aarch64-linux-gnu-objdump -d atomic-default | grep -m1 ldadd
6e0: 94000060 bl 860 <__aarch64_ldadd4_acq_rel>
$ qemu-aarch64 -cpu cortex-a53 -L /usr/aarch64-linux-gnu ./atomic-default
tick_count = 1000
工程上的取舍:
- 产品只跑在一种 SoC 上 :放心用
-mcpu=cortex-a55,性能最好; - 同一个二进制要跑在多种板子上 :
-mcpu取最老的那个核,或者干脆不加,让 outline atomics 在运行时分派; - 我们的工具链文件把 CPU 做成了可配置项,给 A53 的老板子编译时用
-DDOG_CPU=cortex-a53即可。
7.6 现场四:char 的符号性
这是从 x86 移植代码时最隐蔽的一个坑。C 标准没有规定 char 是有符号还是无符号,交给 ABI 决定:
- x86/x86_64 :
char默认 signed; - ARM/AArch64 (AAPCS64 规定):
char默认 unsigned。
体检程序里已经打印了这个差异:x86 上 (char)0xFF = -1,ARM 上是 255。看一个经典的错误写法:
c
/*
* pitfall_char.c - 从 x86 移植到 ARM 时最经典的坑之一
* 在 x86 上正常结束;在 ARM 上死循环(这里用计数器强行截断)
*/
#include <stdio.h>
int main(void)
{
char c; /* 错误:getchar() 返回 int,应该用 int 接 */
int n = 0;
while ((c = getchar()) != EOF) { /* ARM 上 c 永远不等于 -1 */
if (++n > 10) {
printf("读了 %d 次还没遇到 EOF,c = %d ------ 死循环!\n", n, c);
return 1;
}
}
printf("正常结束,共读 %d 字节\n", n);
return 0;
}
getchar() 返回 int,遇到文件结尾时返回 EOF(即 -1)。赋值给 char c 后:
- x86 上
c是 signed char,-1存进去还是-1,和EOF比较相等,循环正常结束; - ARM 上
c是 unsigned char,-1存进去变成255,提升为 int 后和-1永远不相等。
bash
$ printf 'abc' | ./build-x86/pitfall-char
正常结束,共读 3 字节
$ printf 'abc' | qemu-aarch64 -L /usr/aarch64-linux-gnu build-arm/pitfall-char
读了 11 次还没遇到 EOF,c = 255 ------ 死循环!
串口协议解析、二进制文件读取里到处都是这种代码。好消息是,编译器其实会提醒你 ,只要打开 -Wextra:
bash
$ aarch64-linux-gnu-gcc -Wall -Wextra -c src/pitfall_char.c
src/pitfall_char.c:12:28: warning: comparison is always true due to limited range of data type [-Wtype-limits]
12 | while ((c = getchar()) != EOF) { /* ARM 上 c 永远不等于 -1 */
| ^~
注意这个警告只有交叉编译时才出现,在 x86 上编译同样的代码是没有警告的,所以"在 PC 上编译没警告"不能说明任何问题。修复方法:
- 根本修复:用
int接getchar()的返回值;处理字节数据时一律用uint8_t/int8_t,不要用裸char; - 移植大量遗留代码时的权宜之计:加
-fsigned-char编译选项,让 ARM 上的char也变成有符号。
7.7 附加题:结构体对齐
体检程序还打印了这一行:
joint_cmd_naive : sizeof=12, offsetof(pos)=4, offsetof(torque)=10
joint_cmd(packed): sizeof=8 <- 可直接塞进 8 字节 CAN 帧
joint_cmd_naive 的字段加起来只有 1+4+1+2 = 8 字节,实际却占了 12 字节,因为 int32_t 要求 4 字节对齐,编译器在 joint_id 后面插了 3 字节填充,结尾又补了 2 字节,好让数组里下一个元素也对齐。x86_64 和 AArch64 在这个结构体上恰好布局一致,但换成 ARM32 的老 ABI 或者某些 MCU 编译器就不一定了。
凡是要跨设备传输的结构体,三件事缺一不可:固定宽度类型、显式 packed(或者手工排列字段使其自然对齐)、static_assert 锁死长度。 我们的 joint_cmd 把字段按 1、1、2、4 字节排列,每个字段本身就是自然对齐的,所以 packed 后也不会产生非对齐访问。这个细节在第 14 篇 SocketCAN 里还会再讲。
8. 选型对比:静态 vs 动态,glibc vs musl
8.1 静态链接与动态链接
bash
$ make CROSS_COMPILE=aarch64-linux-gnu- # 动态
$ make CROSS_COMPILE=aarch64-linux-gnu- STATIC=1 # 静态
实测文件大小:
| 版本 | 未 strip | strip 后 |
|---|---|---|
| 动态链接 | 71,120 字节 | 67,592 字节 |
| 静态链接 (glibc) | 635,248 字节 | 532,504 字节 |
| 维度 | 动态链接 | 静态链接 |
|---|---|---|
| 体积 | 小(共享库只存一份) | 大(每个程序带一份 libc) |
| 对 rootfs 的依赖 | 依赖加载器和 glibc 版本 | 无依赖,扔到任何 ARM64 Linux 都能跑 |
| 安全更新 | 升级 libc 一处生效 | 每个程序都要重新编译 |
| 启动速度 | 需要动态重定位 | 略快 |
| 典型用途 | rootfs 里的常规程序 | 救援工具、initramfs、"先跑起来再说"的调试程序 |
一个实用技巧:新板子刚拿到、还不清楚 rootfs 是什么情况时,先用静态链接的体检程序跑一遍,静态版本不可能遇到现场一和现场二,能跑就说明架构和内核没问题,然后再看动态版本。
glibc 静态链接有一个限制:调用
getpwnam、getaddrinfo这类走 NSS 的函数时,链接器会警告"静态程序在运行时仍然需要共享库"。需要彻底静态时,用 musl。
8.2 glibc 与 musl
| 维度 | glibc | musl |
|---|---|---|
| 体积 | 大 | 小,静态链接的 hello world 只有几十 KB |
| 兼容性 | 事实标准,第三方库都按它测试 | 少数依赖 glibc 扩展的软件需要打补丁 |
| 静态链接 | 支持但有 NSS 限制 | 一等公民 |
| 性能 | malloc、memcpy 高度优化 |
malloc 在多线程高并发下明显偏慢 |
| 典型用户 | Debian/Ubuntu、Buildroot 默认 | Alpine、OpenWrt |
本专栏的选择:glibc。 机器狗主控要跑多线程控制程序、要用 PREEMPT_RT,后期可能还要接 ROS 2 之类的大型软件栈,glibc 的兼容性最省心。musl 留给第 5 篇做最小 initramfs 时用。
9. 本篇小结
一张图总结本篇:
宿主机 目标板
┌─────────────────────┐ ┌──────────────────────┐
│ main.c │ │ /lib/ld-linux- │ ← 现场一:加载器在不在
│ │ -mcpu=cortex-a55│ ← 现场三:指令扩展 │ aarch64.so.1 │
│ ▼ │ │ /lib/libc.so.6 │ ← 现场二:glibc 版本够不够
│ aarch64-linux-gnu- │ │ (glibc 2.xx) │
│ gcc + sysroot │ check_elf.sh │ │
│ │ │ ─────────────────► │ Cortex-A55 / A53 │
│ ▼ │ 上板前拦截 │ │
│ dogctl-probe (ELF) │ │ ABI: char unsigned │ ← 现场四:ABI 差异
└─────────────────────┘ └──────────────────────┘
记住四条:
- 嵌入式 Linux = Bootloader + Kernel + DTB + rootfs,启动链是特权级逐级下降、每一级为下一级准备环境的接力过程;
- 交叉编译的产物,必须和目标板在四个层面对得上:架构(readelf Machine)、加载器(PT_INTERP)、libc 符号版本(objdump -T)、CPU 扩展(-mcpu 与 HWCAP);
- CMake 工具链文件的核心是 sysroot 和四个 FIND_ROOT_PATH_MODE,工程描述"编什么",工具链文件描述"用什么编";
- 交叉编译时一定打开
-Wall -Wextra,有些 ABI 问题只有交叉编译器才会提醒你。
思考题
dogctl-probe在 QEMU 用户态下打印的"在线 CPU 核数"和"内核版本",为什么是宿主机的?如果用qemu-system-aarch64跑同一个程序,结果会怎样?- 静态链接的程序还会依赖目标板上的什么东西?提示:想想内核版本,
file输出里的for GNU/Linux 3.7.0是什么意思? - 把
joint_cmd的字段顺序改回joint_cmd_naive的顺序,再加packed,长度仍是 8 字节,但在 ARM 上访问pos_mrad字段时会发生什么?
下一篇预告
第 2 篇:U-Boot 深挖 。编译主线 U-Boot 并烧写到 RK3568,读懂 SPL 到 U-Boot proper 的交接,搭建 TFTP + NFS 网络启动环境(改完内核不用烧写),最后用 U_BOOT_CMD 给 U-Boot 加一条自定义命令 dogcheck:在加载内核之前检查电池电压,电量不足时拒绝启动,防止机器狗在半路断电摔倒。
配套代码 :dogctl/ 工程完整代码已在文中列出,可直接按目录结构建立文件后编译。
验证环境:Ubuntu 24.04 / aarch64-linux-gnu-gcc 13.3.0 / glibc 2.39 / CMake 3.28 / QEMU 8.2.2