嵌入式Linux从裸板到产品(一):全景地图与交叉编译环境,从四个上板翻车现场说

源码

本文是专栏《嵌入式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

这里有几个值得记住的硬知识点:

  1. ARM 异常等级(Exception Level) :EL3 是安全监控层(TF-A 常驻于此),EL2 是虚拟化层,EL1 是操作系统内核,EL0 是用户程序。启动过程是特权级逐级下降 的过程,下降之后不能主动回去,只能通过 SMC 指令"请求"上层服务。比如 Linux 关掉一个 CPU 核,实际是通过 PSCI 调用请 EL3 的 BL31 去做。
  2. ARM64 内核不自解压 。x86 和 ARM32 的 zImage/bzImage 自带解压代码,ARM64 没有,U-Boot 加载的是原始的 Image(或者由 U-Boot 解压 Image.gz),用的命令是 booti 而不是 ARM32 的 bootz。
  3. 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,很多人第一次接串口看到满屏乱码就是这个原因。

    bash 复制代码
    sudo apt install picocom
    picocom -b 1500000 /dev/ttyUSB0
  • 网线:开发阶段的效率工具。内核用 TFTP 加载、根文件系统用 NFS 挂载,改完代码不用重新烧写,重启就生效(第 2 篇细讲)。

  • USB OTG :救砖和烧写。板子变砖后按住 Maskrom 键上电,BootROM 会进入 USB 下载模式,用 rkdeveloptool 重新烧写:

    bash 复制代码
    rkdeveloptool 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()

逐段解释:

  1. CMAKE_SYSTEM_NAME :一旦设置,CMake 就认为当前是交叉编译,CMAKE_CROSSCOMPILING 变为真。
  2. -mcpu=cortex-a55 :让编译器针对 A55 调度指令,并且允许使用 A55 支持的全部扩展指令。后半句是现场三的根源,第 7 节细讲。
  3. 四个 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"。
  4. pkg-config 的两个环境变量 :PKG_CONFIG_LIBDIR 替换(而不是追加)搜索路径,PKG_CONFIG_SYSROOT_DIR 给 .pc 文件里的 -I/usr/include 自动加上 sysroot 前缀。第 5 篇引入第三方库时会用到。
  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?最常见的两种:

  1. 根文件系统用的是 musl (加载器叫 /lib/ld-musl-aarch64.so.1),而程序是用 glibc 工具链编的;
  2. 根文件系统是 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 上跑),不保证向前兼容。所以解决方法只有两个:

  1. 用不高于目标板 glibc 版本的工具链编译(Buildroot 或厂商 SDK);
  2. 静态链接。

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 上编译没警告"不能说明任何问题。修复方法:

  1. 根本修复:用 int 接 getchar() 的返回值;处理字节数据时一律用 uint8_t/int8_t,不要用裸 char;
  2. 移植大量遗留代码时的权宜之计:加 -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 差异
  └─────────────────────┘                    └──────────────────────┘

记住四条:

  1. 嵌入式 Linux = Bootloader + Kernel + DTB + rootfs,启动链是特权级逐级下降、每一级为下一级准备环境的接力过程;
  2. 交叉编译的产物,必须和目标板在四个层面对得上:架构(readelf Machine)、加载器(PT_INTERP)、libc 符号版本(objdump -T)、CPU 扩展(-mcpu 与 HWCAP);
  3. CMake 工具链文件的核心是 sysroot 和四个 FIND_ROOT_PATH_MODE,工程描述"编什么",工具链文件描述"用什么编";
  4. 交叉编译时一定打开 -Wall -Wextra,有些 ABI 问题只有交叉编译器才会提醒你。

思考题

  1. dogctl-probe 在 QEMU 用户态下打印的"在线 CPU 核数"和"内核版本",为什么是宿主机的?如果用 qemu-system-aarch64 跑同一个程序,结果会怎样?
  2. 静态链接的程序还会依赖目标板上的什么东西?提示:想想内核版本,file 输出里的 for GNU/Linux 3.7.0 是什么意思?
  3. 把 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

相关推荐
CIMPro孪大师1 小时前
CIMPro×DPE | 不止是爆炸动画,AI原生数字样机如何让设备培训与运维更高效
运维·ai-native
皓月盈江1 小时前
Linux系统PC与Linux系统服务器通过scp上传与下载文件
linux·运维·服务器·scp·文件上传·文件下载
对讲机数码科普1 小时前
危化厂区防爆专网通信建设实践:从合规框架到验收清单
运维·网络·架构
Ruiery1 小时前
Linux 6.6内核 CPU 启动深度解析(二):AP 拉起 — BSP 如何用 INIT-SIPI 唤醒其余 CPU
linux·运维·服务器
Lsetea1 小时前
OpenSSL verify报error 62:证书主机名不匹配与-verify_hostname排查
运维·https·ssl证书·openssl·san
xcLeigh1 小时前
【KingbaseES数据库教程】国产化信创背景下的数据库选型与初识
linux·数据库·windows·kes·数据选型
rm -rf * && haha.sh1 小时前
【Linux】Rocky 9.8 操作系统磁盘分区与MySQL数据目录迁移
linux·运维·mysql
anew___2 小时前
《从零手写操作系统 (20):ELF加载器——让OS读懂现代编译器》
java·服务器·前端
高山有多高2 小时前
【Linux笔记】Socket编程基础
linux