2. 开发环境搭建:交叉编译工具链安装、Makefile基础、链接脚本(.ld)编写入门

好,咱们正式开始动手了。

上一章我们聊了嵌入式图像处理到底是个什么玩意儿。这一章,咱们得先把家伙事儿备齐。你想想看,没有工具,再牛的算法也跑不起来。

我个人习惯把环境搭建分成三块:交叉编译工具链Makefile链接脚本。这三样东西,说白了就是:
- 工具链:让你能在PC上编译出ARM芯片能跑的程序。
- Makefile:让你不用每次都敲一长串命令。
- 链接脚本:告诉编译器,你的代码该放哪儿,数据该放哪儿。

嗯,咱们一个一个来。

2.1 交叉编译工具链安装

什么是交叉编译?

你的电脑是x86架构,但你的目标板是ARM架构。你不能直接在PC上编译一个程序然后扔到ARM板上跑——指令集都不一样。所以我们需要一个交叉编译器,它在x86上运行,但生成ARM的机器码。

核心概念: 交叉编译 = 在A平台上编译出B平台能运行的程序。

我刚开始做嵌入式那会儿,就犯过傻。直接在Ubuntu上用gcc编译了一个程序,拷到ARM板上,执行报错:./hello: cannot execute binary file: Exec format error。嗯,当时我还纳闷了半天,后来才反应过来——架构不对。

2.1.1 安装ARM交叉编译器

以最常用的ARM Cortex-A系列为例,我们使用arm-linux-gnueabihf-这套工具链。

方法一:apt安装(推荐新手)

sudo apt-get update
sudo apt-get install gcc-arm-linux-gnueabihf
sudo apt-get install g++-arm-linux-gnueabihf

装完之后,验证一下:

arm-linux-gnueabihf-gcc --version

看到版本号,说明装好了。

方法二:从Linaro官网下载(推荐老手)

我个人习惯用Linaro的预编译工具链,版本更新快,优化也好。下载后解压,然后配置环境变量:

export PATH=$PATH:/path/to/gcc-linaro-7.5.0-2019.12-x86_64_arm-linux-gnueabihf/bin

小技巧: 把上面这行加到 ~/.bashrc 里,这样每次打开终端就不用重新export了。

2.1.2 验证工具链

写一个最简单的C程序:

// hello.c
#include <stdio.h>

int main() {
    printf("Hello, Embedded World!\n");
    return 0;
}

用交叉编译器编译:

arm-linux-gnueabihf-gcc hello.c -o hello_arm

查看生成的文件信息:

file hello_arm

你会看到类似这样的输出:

hello_arm: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3

看到ARM字样,说明编译成功了。嗯,这一步走通了,后面就好办了。

注意: 如果你用的是64位ARM芯片(比如Cortex-A53),请使用 aarch64-linux-gnu- 前缀的工具链。别搞混了,我见过有人拿32位工具链去编译64位程序,结果链接阶段各种报错。

2.2 Makefile基础

为什么要学Makefile?

你想想看,一个图像处理项目,少说也有几十个源文件。每次改一个文件,你都要重新敲一遍编译命令?那不得累死。Makefile就是帮你自动化这件事的。

说白了,Makefile就是一套规则:告诉make工具,哪个文件依赖哪个文件,怎么编译,怎么链接。

2.2.1 最简单的Makefile

# Makefile
all: hello_arm

hello_arm: hello.c
    arm-linux-gnueabihf-gcc hello.c -o hello_arm

clean:
    rm -f hello_arm

使用方法:

make        # 编译
make clean  # 清理

这里要注意:gcc前面那个缩进必须是Tab键,不能是空格。我当年在这个坑上栽过,Makefile报错missing separator,查了半天才发现是空格的问题。

2.2.2 带变量的Makefile

实际项目中,我们不会把编译器名字写死。用变量管理更方便:

# Makefile
CROSS_COMPILE = arm-linux-gnueabihf-
CC = $(CROSS_COMPILE)gcc
CFLAGS = -Wall -O2 -march=armv7-a

TARGET = image_process
SRCS = main.c utils.c filter.c
OBJS = $(SRCS:.c=.o)

all: $(TARGET)

$(TARGET): $(OBJS)
    $(CC) $(CFLAGS) -o $@ $^

%.o: %.c
    $(CC) $(CFLAGS) -c -o $@ $<

clean:
    rm -f $(OBJS) $(TARGET)

解释几个关键点:

  • $@:目标文件名
  • $^:所有依赖文件
  • $<:第一个依赖文件
  • %.o: %.c:模式规则,把所有.c文件编译成对应的.o文件

个人经验: 我习惯把CFLAGS里加上-march=armv7-a这样的架构优化参数。对于图像处理这种计算密集型任务,CPU指令集的优化能带来10%-20%的性能提升。别小看这点提升,在嵌入式设备上,每一分性能都很宝贵。

2.2.3 多目录管理

当项目变大,源文件会分散在不同目录。比如:

project/
├── src/
│   ├── main.c
│   ├── filter.c
│   └── utils.c
├── inc/
│   ├── filter.h
│   └── utils.h
└── Makefile

对应的Makefile:

CROSS_COMPILE = arm-linux-gnueabihf-
CC = $(CROSS_COMPILE)gcc
CFLAGS = -Wall -O2 -I./inc

SRC_DIR = ./src
OBJ_DIR = ./obj
TARGET = image_process

SRCS = $(wildcard $(SRC_DIR)/*.c)
OBJS = $(patsubst $(SRC_DIR)/%.c, $(OBJ_DIR)/%.o, $(SRCS))

all: $(TARGET)

$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
    $(CC) $(CFLAGS) -c -o $@ $<

$(TARGET): $(OBJS)
    $(CC) $(CFLAGS) -o $@ $^

$(OBJ_DIR):
    mkdir -p $@

clean:
    rm -rf $(OBJ_DIR) $(TARGET)

这里用了wildcardpatsubst两个函数,自动扫描源文件并生成目标文件路径。嗯,这样你新增一个.c文件,Makefile不用改,省心。

2.3 链接脚本(.ld)编写入门

链接脚本,很多人觉得难,其实没那么玄乎。

它的作用就是告诉链接器:你的代码段放哪个地址?数据段放哪个地址?堆栈设多大?

在无操作系统的裸机环境下,没有MMU帮你做地址映射,所以你必须自己定义好内存布局。

2.3.1 一个典型的链接脚本

/* link.ld */
OUTPUT_FORMAT("elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(_start)

SECTIONS
{
    /* 代码段从0x80000000开始(常见于SDRAM起始地址) */
    . = 0x80000000;

    .text : {
        *(.text)
        *(.rodata)
    }

    /* 数据段 */
    . = ALIGN(4);
    .data : {
        *(.data)
    }

    /* BSS段(未初始化全局变量) */
    . = ALIGN(4);
    .bss : {
        __bss_start = .;
        *(.bss)
        *(COMMON)
        __bss_end = .;
    }

    /* 堆栈设置 */
    . = ALIGN(8);
    . = . + 0x10000;  /* 64KB堆栈 */
    __stack_top = .;
}

关键点解释:

  • OUTPUT_FORMAT:指定输出格式,ARM小端模式
  • ENTRY(_start):程序入口,通常是汇编写的启动代码
  • . = 0x80000000:当前地址设为0x80000000,后续段从这里开始放
  • *(.text):把所有目标文件的.text段合并到这里
  • __bss_start__bss_end:BSS段的起止地址,启动代码里要用这两个符号来清零BSS段

我曾经踩过的坑: 有一次做摄像头图像采集项目,程序跑起来总是一卡一卡的。查了两天,最后发现是堆栈设得太小,才4KB。图像处理函数里有个局部数组,直接栈溢出了。从那以后,我习惯在链接脚本里至少留64KB的堆栈空间,如果用到递归或者大数组,直接给256KB。

2.3.2 带RAM和ROM分离的链接脚本

很多嵌入式芯片有片内Flash和片内RAM。代码放在Flash里,运行时数据放在RAM里。这时候链接脚本要复杂一些:

/* link.ld */
MEMORY
{
    FLASH (rx)  : ORIGIN = 0x08000000, LENGTH = 512K
    RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

SECTIONS
{
    /* 代码段放在Flash */
    .text : {
        *(.text)
        *(.rodata)
    } > FLASH

    /* 初始化数据段:运行时在RAM,但初始值保存在Flash */
    .data : {
        __data_start = .;
        *(.data)
        __data_end = .;
    } > RAM AT > FLASH

    /* BSS段 */
    .bss : {
        __bss_start = .;
        *(.bss)
        *(COMMON)
        __bss_end = .;
    } > RAM

    /* 堆栈 */
    . = ALIGN(8);
    . = . + 0x10000;
    __stack_top = .;
}

这里有个关键点:> RAM AT > FLASH。意思是:.data段运行时在RAM里,但它的初始值保存在Flash里。启动代码需要把这段数据从Flash拷贝到RAM。

为什么这么做? 因为RAM掉电丢失,Flash掉电不丢失。程序启动时,从Flash把初始化数据拷贝到RAM,然后才能正常使用全局变量。

2.3.3 在Makefile中使用链接脚本

LDFLAGS = -T link.ld -nostdlib

$(TARGET): $(OBJS)
    $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ -lgcc

-T link.ld指定使用我们写的链接脚本。-nostdlib表示不使用标准C库的启动文件,因为裸机环境下没有操作系统帮你做这些初始化工作。

2.4 实战:编译第一个裸机程序

好,理论说完了,咱们动手试试。

步骤1:准备文件

创建三个文件:

  • start.S:汇编启动代码
  • main.c:主程序
  • link.ld:链接脚本
  • Makefile:编译规则

start.S

.global _start
_start:
    /* 设置栈指针 */
    ldr sp, =__stack_top

    /* 清零BSS段 */
    ldr r0, =__bss_start
    ldr r1, =__bss_end
    mov r2, #0
loop:
    cmp r0, r1
    bge done
    str r2, [r0], #4
    b loop
done:
    /* 跳转到main函数 */
    bl main
    /* 死循环 */
    b .

main.c

void main(void) {
    volatile unsigned int *led = (unsigned int *)0x40020C14;
    *led = 0x01;  // 点亮LED(假设地址)
    while(1);
}

编译并查看结果

make
arm-linux-gnueabihf-objdump -d image_process > disasm.txt

打开disasm.txt,你会看到:

80000000 <_start>:
80000000:   e59fd000    ldr sp, [pc, #0]
80000004:   e59f0018    ldr r0, [pc, #24]
...

看到地址从0x80000000开始,说明链接脚本生效了。

我的建议: 第一次做裸机开发,别急着写复杂算法。先点亮一个LED,证明你的编译环境、链接脚本、启动代码都是对的。这一步走通了,后面图像处理的事情才有基础。

2.5 本章小结

这一章我们干了三件事:

  1. 装了交叉编译工具链——让你的PC能编译ARM程序
  2. 写了Makefile——让编译自动化,不用每次都敲长命令
  3. 写了链接脚本——告诉编译器内存怎么布局

这些东西看起来琐碎,但它们是嵌入式开发的基石。我见过太多人一上来就写算法,结果编译不过、链接报错、运行崩溃,最后发现是环境没搭好。

嗯,磨刀不误砍柴工。下一章,咱们开始真正接触图像处理——从BMP文件格式说起。