文章目录
-
- 前言
- 一、GNU工具链常用工具说明
-
- [1.1 gcc (GNU Compiler Collection)](#1.1 gcc (GNU Compiler Collection))
- [1.2 ld (GNU Linker)](#1.2 ld (GNU Linker))
- [1.3 objcopy (Binary File Converter)](#1.3 objcopy (Binary File Converter))
- [1.4 objdump (Object File Disassembler)](#1.4 objdump (Object File Disassembler))
- 二、Makefile
-
- [2.1 Makefile基本语法](#2.1 Makefile基本语法)
- [2.2 简易版Makefile](#2.2 简易版Makefile)
- [2.3 链接脚本](#2.3 链接脚本)
- [2.4 多目录工程Makefile](#2.4 多目录工程Makefile)
- [三、 内存段(Memory Segments)](#三、 内存段(Memory Segments))
-
- [3.1 bss段](#3.1 bss段)
- [3.2 COMMON段](#3.2 COMMON段)
- [3.3 data段](#3.3 data段)
- [3.4 rodata段(只读数据段)](#3.4 rodata段(只读数据段))
- [3.5 各段的特点比较](#3.5 各段的特点比较)
- [3.6 在链接脚本中的定义](#3.6 在链接脚本中的定义)
- [3.7 关于段名中的通配符说明](#3.7 关于段名中的通配符说明)
- 四、C语言实现点亮LED
- 五、C语言实现蜂鸣器

前言
在上篇【ARM裸机开发(IMX6ULL-mini)】arm上的点灯大师:环境配置、基础知识学习及点亮LED灯文章中我们完成了对imx6ull上的LED点灯操作,但是大家会发现,当你修改过代码后,再次编译下载又需要执行这么多指令。效率太低,这时候就得想到老朋友Makefile了。在这篇文章中将分享GNU工具链的说明,也就是上篇文章指令使用到的相关工具的说明,然后就是Makefile工具和引入NXP公司封装的库以及用借助库C语言实现点亮LED灯和使蜂鸣器发声。
一、GNU工具链常用工具说明
1.1 gcc (GNU Compiler Collection)
功能:编译器(Compiler)
- 将C/C++等高级语言源代码转换为汇编代码
- 再将汇编代码转换为目标文件
- 支持多种编程语言和目标平台
- 提供丰富的优化选项和编译控制选项
1.2 ld (GNU Linker)
功能:链接器(Linker)
- 将多个目标文件链接成可执行文件
- 解析和处理符号引用
- 进行地址分配和重定位
- 处理库文件的链接
1.3 objcopy (Binary File Converter)
功能:目标文件格式转换器
- 在不同格式的目标文件之间进行转换
- 将ELF格式转换为纯二进制格式(.bin)
- 从目标文件中提取特定的段(sections)
- 修改目标文件的内容
- 常用于嵌入式开发中的固件生成
1.4 objdump (Object File Disassembler)
功能:目标文件分析器/反汇编器
- 将机器码反汇编为汇编代码
- 查看目标文件的详细信息
- 分析程序的执行指令
- 显示各种文件格式信息
二、Makefile
2.1 Makefile基本语法
对于Makefile,大家可以先看看下面这篇文章,关于Makefile的基本使用。
Makefile有三个要素:
- 目标(目标文件)
- 依赖(源文件)
- 规则
2.2 简易版Makefile
在了解Makefile的基本语法后,我们可以根据指令写出这样一个简易版的Makefile:
c
start.bin : start.S
arm-linux-gnueabihf-gcc -g -c start.S -o start.o
arm-linux-gnueabihf-ld -Ttext 0X87800000 start.o -o start.elf
arm-linux-gnueabihf-objcopy -O binary -S -g start.elf start.bin
arm-linux-gnueabihf-objdump -D start.elf > start.dis
clean :
rm -f start.bin start.elf start.o start.dis
但在实现C语言点亮LED灯后我们又会发现,会有main.c start.S多个源文件需要编译。试想一下随着后面功能的增多,会有越来越多的源文件,像这样写Makefile,在每次增加功能后都需要更改Makefile。所以,我们对上面的Makefile进行完善:
c
target = led
objs = start.o main.o led.o
$(target).bin : $(objs)
arm-linux-gnueabihf-ld -Ttext 0X87800000 $(objs) -o $(target).elf
arm-linux-gnueabihf-objcopy -O binary -S -g $(target).elf $(target).bin
arm-linux-gnueabihf-objdump -D $(target).elf > $(target).dis
%.o : %.S
arm-linux-gnueabihf-gcc -g -c $^ -o $@
%.o : %.c
arm-linux-gnueabihf-gcc -g -c $^ -o $@
clean :
rm -f $(target).bin $(target).elf $(target).dis $(objs)
- target :定义变量 target,最终生成中间固件及最后下载程序名字前缀
led; - objs :所有需要参与链接的目标文件:汇编
start.S、C 文件main.c、led.c编译出的.o; - (target).bin : (objs) :最终目标
led.bin依赖 start.o main.o led.o,只要其中任意.o 更新就会重新执行下面命令; - %.o : %.c (%.o : %.S ):
%是 Makefile 的通配符 ,用于模式规则(pattern rule) ;%.o:匹配xxx.o,%捕获xxx,含义就是:任意.o文件由同名的.c文件生成; -c:只编译不链接;$^代表所有依赖文件;$@代表目标文件
不难发现,在arm-linux-gnueabihf-ld -Ttext 0X87800000 $(objs) -o $(target).elf这条指令中,我们写了一个固定的数据-Ttext 0X87800000;
为什么这样写,这就得解释一下了:由于.o 是单独编译的碎片 ,CPU 不能直接跑一堆零散.o;ld链接器将多个目标文件链接成可执行文件,链接的本质就是把碎片拼成一整个程序,并且规定好每一块在内存的位置,最后打包成 elf。
特别注意 :- start.o 是启动汇编,必须放在最开头,上电第一条指令- main.o、led.o 是业务 C 代码,放在后面;
链接器把所有.o里面各自的.text全部合并成一个大的.text 段 ;所有.rodata合并成一个大.rodata,以此类推。
像这样直接写死地址,对我们在后续的代码编写中是不方便的,因此,我们将会使用一个链接脚本来代替这个地址。
2.3 链接脚本
链接脚本 :
链接脚本是给链接器 ld 看的配置文件 ,告诉 arm-linux-gnueabihf-ld:
把一堆编译出来的 .o 目标文件,怎么拼接、放到哪个内存地址、怎么划分段,最终生成 elf 文件。概括来说就是哦:规定程序的内存布局。
语法格式 :

-
0x10000000;:定程序的链接起始地址 ,告诉链接器:所有代码从 0x10000000 这个内存地址开始链接。 -
划分各个段:
.text/.rodata/.data/.bss.text:代码(汇编、C 指令).rodata:const 只读常量.data:已经初始化的全局变量.bss:未初始化全局变量(不占用 bin 文件大小)
后面会单独一个章节来讲这些段的含义。
-
ALIGN(4):ARM32 要求 4 字节对齐,避免内存访问异常。ALIGN(4)让段起始地址 4 字节对齐。
上面只是一个框架,实际代码需要根据实际程序来编写,来看我们点亮LED的链接脚本:
c
SECTIONS
{
. = 0x87800000;
.text :
{
start.o
*(.text)
}
.rodata ALIGN(4) : {*(.rodata*)}
.data ALIGN(4) : {*(.data)}
. = ALIGN(4);
__bss_start = .;
.bss ALIGN(4) : {*(.bss) *(COMMON)}
__bss_end = .;
}
. = 0x87800000;指定程序的链接起始地址 :告诉链接器:所有代码从 0x87800000 这个内存地址开始链接。- 划分各个段:
.text/.rodata/.data/.bss; .text大括号中:控制目标文件的存放顺序:强制start.o 放在代码段最开头。__bss_start = .;__bss_end = .;定义全局符号,给 C / 汇编代码使用,把当前位置计数器.赋值给符号,导出到 elf。ALIGN(4):设置内存对齐。
链接脚本写好了,接下来就是应用到Makefile中了:
将-Ttext 0X87800000替换为-Timx6ull.lds(链接脚本的名字,lds是固定后缀)就可以了。
c
arm-linux-gnueabihf-ld -Timx6ull.lds $(objs) -o $(target).elf
2.4 多目录工程Makefile
这样的Makefile算得上比较完整了,但是到后面你又会发现,这么多文件,什么库文件、源文件、外设相关的文件都在一个文件夹里,显得十分杂乱,不好使用,因此我们一般将其放在四个文件夹中:bsp、imx6ull、obj、project;
bsp:存放外设相关的文件(板载文件);imx6ull:存放芯片相关的库文件;obj:存放相关的编译输出文件;project:存放主函数及启动文件。
由于这些文件的路径发生了改变,因此Makefile的代码也需要改变:
c++
target = led
cross_compiler = arm-linux-gnueabihf-
cc = $(cross_compiler)gcc
ld = $(cross_compiler)ld
objcopy = $(cross_compiler)objcopy
objdump = $(cross_compiler)objdump
incdirs = bsp imx6ull
srcdirs = bsp project
include = $(patsubst %, -I%, $(incdirs))
cfiles = $(foreach dir, $(srcdirs), $(wildcard $(dir)/*.c))
sfiles = $(foreach dir, $(srcdirs), $(wildcard $(dir)/*.S))
cfilenodir = $(notdir $(cfiles))
sfilenodir = $(notdir $(sfiles))
cobjs = $(patsubst %, obj/%, $(cfilenodir:.c=.o))
sobjs = $(patsubst %, obj/%, $(sfilenodir:.S=.o))
objs = $(cobjs) $(sobjs)
VPATH = $(srcdirs)
$(target).bin : $(objs)
$(ld) -Timx6ull.lds -o$(target).elf $^
$(objcopy) -O binary -S -g $(target).elf $@
$(objdump) -D $(target).elf > $(target).dis
$(sobjs) : obj/%.o : %.S
@mkdir -p obj
$(cc) -Wall -nostdlib -c $(include) -o $@ $<
$(cobjs) : obj/%.o : %.c
@mkdir -p obj
$(cc) -Wall -nostdlib -c $(include) -o $@ $<
.PHONY : clean
clean:
rm -rf $(objs) $(target).elf $(target).bin $(target).dis
load:
./../imxdownload $(target).bin /dev/sdb

上面就是一个较为完整且通用的Makefile啦。
三、 内存段(Memory Segments)
3.1 bss段
- 用于存放未初始化或初始化为0的数据
- 在运行时会被自动清零
- 典型例子:
c
static int bss_var; // 静态变量(全局或局部)
int explicit_var = 0; // 显式初始化为0的全局变量
3.2 COMMON段
- 用于存放未初始化的全局变量
- 链接时才确定最终大小和位置
- 允许多个目标文件定义同名符号
- 典型例子:
c
int common_var; // 未初始化的非静态全局变量
3.3 data段
- 用于存放已初始化的全局变量和静态变量(非零值)
- 需要在程序文件中保存实际的初始值
- 典型例子:
c
int global_var = 100; // 初始化为非零值
int global_array[] = {1,2,3}; // 初始化为非零数组
3.4 rodata段(只读数据段)
- 存放程序的只读数据
- 运行时受保护,不可修改
- 包含的数据类型:
- 字符串常量
- const修饰的全局变量
- 全局只读数组
- switch跳转表
- 浮点数常量
- 典型例子:
c
const int MAX_VALUE = 100; // const全局变量
char* str = "Hello World"; // 字符串常量
const int lookup[] = {1,2,3}; // 只读数组
3.5 各段的特点比较
| 段名 | 初始化 | 运行时可写 | 链接特性 |
|---|---|---|---|
| .bss | 自动清零 | 可写 | 编译时确定大小 |
| COMMON | 自动清零 | 可写 | 链接时确定大小 |
| .data | 需要初始值 | 可写 | 编译时确定大小 |
| .rodata | 需要初始值 | 只读 | 编译时确定大小 |
3.6 在链接脚本中的定义
c
SECTIONS
{
.text : { ... } // 代码段
.rodata ALIGN(4) : {*(.rodata*)} // 只读数据段
.data ALIGN(4) : {*(.data)} // 已初始化数据段
.bss ALIGN(4) : {*(.bss) *(COMMON)} // 未初始化数据段
}
3.7 关于段名中的通配符说明
在链接脚本中,经常会看到类似*(.rodata*)这样的写法,这里包含两个*通配符,它们有不同的含义:
-
第一个
*:- 表示收集所有输入文件中的相关段
- 如果不加这个
*,就只会收集特定文件的段
-
第二个
*(如rodata*中的*):- 用于匹配所有以
.rodata开头的段名 - 实际编译时会产生多种rodata相关的段,例如:
.rodata:基本的只读数据段.rodata.str1.1:长度为1字节对齐的字符串常量.rodata.str1.4:长度为4字节对齐的字符串常量.rodata.cst8:8字节常量(如double类型的常量).rodata.cst4:4字节常量(如float类型的常量)
- 用于匹配所有以
如果只写*(.rodata)而不是*(.rodata*):
- 只会收集严格命名为
.rodata的段 - 其他相关的只读数据段(如
.rodata.str1.1等)会被遗漏 - 这些被遗漏的数据可能导致程序无法正常工作
这种通配符的使用是链接脚本中的常见做法,类似的还有:
*(.text*):收集所有代码相关的段*(.data*):收集所有数据相关的段
四、C语言实现点亮LED
在启动代码中我们让其在复位异常中跳转到C的主函数中,这样就引导程序进入了C的环境,就可以使用C语言来编写程序了。
实现流程 :

所谓的操作外设,其实就是对其所对应的寄存器进行操作,根据我们上篇文章中的汇编代码,我们可以这样来写C代码:
c
#define CCM_CCGR0 (*((volatile unsigned int *)0x020C4068)) //20C_4068h
#define CCM_CCGR1 (*((volatile unsigned int *)0x020C406C)) //20C_406Ch
#define CCM_CCGR2 (*((volatile unsigned int *)0x020C4070))
#define CCM_CCGR3 (*((volatile unsigned int *)0x020C4074))
#define CCM_CCGR4 (*((volatile unsigned int *)0x020C4078))
#define CCM_CCGR5 (*((volatile unsigned int *)0x020C407C))
#define CCM_CCGR6 (*((volatile unsigned int *)0x020C4080))
#define SW_MUX_CTL_PAD_GPIO1_IO03 (*((volatile unsigned int *)0x020E0068)) //20E_0068h
#define SW_PAD_CTL_PAD_GPIO1_IO03 (*((volatile unsigned int *)0x020E02F4)) //20E_02F4h
#define GPIO1_GDIR (*((volatile unsigned int *)0x0209C004)) //209_C004
#define GPIO1_DR (*((volatile unsigned int *)0x0209C000)) //209_C000
0x020C4068:寄存器物理地址(volatile unsigned int *):把这个数字强制转换成无符号 32 位指针- 最外层
*:解引用指针,直接读写该地址上的值 volatile非常关键
告诉编译器:这个变量是硬件寄存器,值会被硬件自动改变,不要做优化、不要缓存到寄存器,每次读写都必须访问真实内存。
裸机寄存器操作不加 volatile,编译器可能会优化掉读写代码,代码直接失效!
例如
r1 = r1这种操作,在编译器眼里可能是没有作用的,就不执行这行代码了。
将LED这个外设所设计到的寄存器进行宏定义,通过操作这个宏来达到操作寄存器,从而操作外设的目的;但是不难发现,外设涉及到的寄存器这么多。每换一个外设就需要定义新的宏,并且还需要不停的翻手册查地址,这样编写程序的效率太低了,因此,大部分的芯片厂商都会提供一个封装好了的函数库,方便用户编写程序。下面就是厂家提供的相关的库。

- MCIMX6Y2 :定义全部外设寄存器地址、寄存器位掩码 。就是:
CCM_CCGR1、GPIO1_DR、GPIO1_GDIR这些宏,官方已经全部帮你写好了,不用自己手动算地址写宏。 - fsl_iomuxc.h :IO 复用专用头文件定义所有引脚复用宏、PAD 配置的参数宏。比如
IOMUXC_GPIO1_IO03_GPIO1_IO03,直接一句话把引脚复用为 GPIO1_IO03。 专门简化 IO 引脚复用、PAD 电气属性配置。 fsl_common.h【通用工具函数 / 宏】
fsl = Freescale(飞思卡尔,被 NXP 收购)
✅ 作用:
提供 SDK 通用基础宏、位操作工具、中断、时钟通用函数,比如IOMUXC_SetPinMux()、IOMUXC_SetPinConfig(),引脚配置 API 就在这个头文件声明。core_ca7.h【CMSIS CA7 内核头文件】
CMSIS ARM 标准内核头文件,Cortex‑A7 专用
✅ 作用:
操作 CPU 内核本身:CPSR、MMU、Cache、中断寄存器、MPU 等内核相关寄存器。
裸机启动、开启 Cache、操作内核寄存器时用到;普通 LED 驱动一般暂时不用。cc.h【编译器适配头文件】
compiler compatibility,编译器兼容层。
✅ 作用:
对不同编译器(arm-linux-gnueabihf-gcc、armclang、Keil armcc)做适配,定义__IO、__I、__O宏,替代手写volatile unsigned int。 比如:#define __IO volatile,SDK 里寄存器声明用__IO uint32_t。
在手册中我们可以发现很多寄存器的地址是连续的,因此在厂家提供的库函数中,它对寄存器的宏定义的原理是这样的:
c
//使用结构体来使用寄存器
typedef struct
{
volatile unsigned int DR;
volatile unsigned int GDIR;
volatile unsigned int PSR;
volatile unsigned int ICR_1;
volatile unsigned int ICR_2;
volatile unsigned int IMR;
volatile unsigned int ISR;
volatile unsigned int EDGE_SEl;
}GPIO_TYPE_t;
#define GPIO1 ((GPIO_TYPE_t *)(0x0209C000))
这样就可以通过访问这个宏指向的不同寄存器进行操作了。
利用库函数完成LED代码示例:
main.c
c
#include "MCIMX6Y2.h"
#include "fsl_iomuxc.h"
#include "led.h"
void enable_clocks(void)
{
CCM->CCGR0 = 0xFFFFFFFF;
CCM->CCGR1 = 0xFFFFFFFF;
CCM->CCGR2 = 0xFFFFFFFF;
CCM->CCGR3 = 0xFFFFFFFF;
CCM->CCGR4 = 0xFFFFFFFF;
CCM->CCGR5 = 0xFFFFFFFF;
CCM->CCGR6 = 0xFFFFFFFF;
}
void delay(unsigned int n)
{
while(n--);
}
int main(void)
{
enable_clocks();
led_init();
led_on();
while(1)
{
led_nor();
delay(0xaffff);
}
return 0;
}
led.c:
c
#include "MCIMX6Y2.h"
#include "fsl_iomuxc.h"
#include "led.h"
void led_init()
{
//引脚复用功能
IOMUXC_SetPinMux(IOMUXC_GPIO1_IO03_GPIO1_IO03, 0);
//引脚电气属性
IOMUXC_SetPinConfig(IOMUXC_GPIO1_IO03_GPIO1_IO03, 0x10B0);
//引脚方向
GPIO1->GDIR |= (1 << 3);
}
void led_on()
{
GPIO1->DR &= ~(1 << 3);
}
void led_off()
{
GPIO1->DR |= (1 << 3);
}
void led_nor()
{
GPIO1->DR ^= (1 << 3);
}
五、C语言实现蜂鸣器
实现流程 :

由于IMX6ULL开发板的蜂鸣器是一个有源蜂鸣器,所以只需要对齐输出高低电平就可以令其发声。


通过原理图,我们可以找到蜂鸣器所接的引脚的默认功能为SNVS TAMPER1,进而我们可以在手册中找到


然后根据手册的解释,就可以完成对这个引脚的相关操作了。
代码示例:
beep.c:
c
#include "MCIMX6Y2.h"
#include "fsl_iomuxc.h"
#include "beep.h"
void beep_init(void)
{
//引脚复用功能
IOMUXC_SetPinMux(IOMUXC_SNVS_SNVS_TAMPER1_GPIO5_IO01, 0);
//引脚电气属性
IOMUXC_SetPinConfig(IOMUXC_SNVS_SNVS_TAMPER1_GPIO5_IO01, 0x10B0);
//引脚方向
GPIO5->GDIR |= (1 << 1);
}
void beep_on(void)
{
GPIO5->DR &= ~(1 << 1);
}
void beep_off(void)
{
GPIO5->DR |= (1 << 1);
}
void beep_nor(void)
{
GPIO5->DR ^= (1 << 1);
}
main.c:
c
#include "MCIMX6Y2.h"
#include "fsl_iomuxc.h"
#include "led.h"
#include "beep.h"
void enable_clocks(void)
{
CCM->CCGR0 = 0xFFFFFFFF;
CCM->CCGR1 = 0xFFFFFFFF;
CCM->CCGR2 = 0xFFFFFFFF;
CCM->CCGR3 = 0xFFFFFFFF;
CCM->CCGR4 = 0xFFFFFFFF;
CCM->CCGR5 = 0xFFFFFFFF;
CCM->CCGR6 = 0xFFFFFFFF;
}
void delay(unsigned int n)
{
while(n--);
}
int main(void)
{
enable_clocks();
led_init();
beep_init();
//beep_on();
led_on();
while(1)
{
led_nor();
beep_nor();
delay(0xaffff);
}
return 0;
}