简介:北京迅为电子有限公司发布的 i.MX6ULL 终结者开发板裸机使用手册(v1.0),是一份面向嵌入式入门与进阶开发者的官方技术文档,重点讲明 Cortex-A7 处理器的架构特性、多种运行模式、寄存器组及通用寄存器用途,帮助读者在无操作系统环境下完成裸机程序开发与板级外设驱动编写。资源仅包含 1 个 PDF 文件,压缩包大小约 14.05MB,目录从 Cortex-A7 架构展开,依次阐述运行模式与寄存器组,之后补充开发板使用注意事项、售后维修流程、技术支持方式及资料获取渠道,结构清晰,便于按章节检索。手册对 User、IRQ、FIQ、SVC、ABT、UND 等运行模式逐一说明,并介绍 R0 至 R12 通用寄存器、堆栈指针、链接寄存器和程序计数器的用途,有助于读者理解程序跳转与中断现场保护等裸机开发关键环节;同时列举避免带电插拔、使用配套电源等硬件保护要点,降低动手实践中的损坏风险。目前已有 258 人学习下载,对于需要快速上手 i.MX6ULL 底层开发的工程师和学生,是一份值得收藏的参考资料。
1. 裸机开发,先从关掉操作系统开始
很多人拿到 i.MX6ULL 开发板的第一反应就是上 Linux,跑 Qt、接摄像头、搞网络服务。但这个思路对入门来说其实绕了远路。我拆这份裸机手册时最大的感受是:不带操作系统,直接操作寄存器,才是理解 ARM 处理器内核、启动流程、外设地址映射最快的方式。手册基于 Cortex-A7 架构,把运行模式、寄存器组、ARM 汇编、GPIO/时钟/中断/定时器全部串成完整实验链,从点亮第一颗 LED 到跑通 EPIT 定时器按键扫描,每一步都有原理图和寄存器说明。适合两类人:一是刚接触 ARM 开发板、想从单片机思维过渡到应用处理器的新手;二是已经跑过 Linux、但想回头补齐底层寄存器功底的开发者。这份文档不是给你查 API 用的,是让你照着写汇编、改链接脚本、操作 GIC 控制器,真正把芯片跑起来。
2. 从 Cortex-A7 架构入手:运行模式和寄存器背后是启动代码的根基
2.1 七种运行模式,决定了你的启动代码怎么编排
手册第一章花了大量篇幅讲 Cortex-A7 的七种运行模式:USR、SYS、SVC、ABT、UND、IRQ、FIQ。可能有人觉得这是纯理论,但裸机启动代码里的异常向量表、栈指针切换、中断处理现场保护全都依赖这些模式。比如处理器上电默认进入 SVC 模式,这就是为什么所有裸机工程的 start.S 第一件事就是设置 SVC 模式下的栈指针。
模式切换是靠修改 CPSR 寄存器的 M[4:0] 位实现的。常见做法是先用 MRS 读出 CPSR,再 BIC 清零模式位,最后 ORR 写入目标模式值。手册里的 LED 汇编实验就是这么干的。我一般在 start.S 里会把 IRQ、FIQ、SVC、ABT、UND 五种模式的栈都初始化一遍,虽然裸机程序用不了那么多,但后面写中断服务函数时 IRQ 栈必须就位,否则一进中断现场压栈就会跑飞。
ARM 模式下每个异常对应一个固定的入口地址,也就是异常向量表。Cortex-A7 的向量表基址在 0x00000000(或通过 VBAR 寄存器重定位),每个向量占 4 字节,存放一条跳转指令。你得自己保证这条跳转指令能从任意位置跳回主程序。手册里用 LDR PC, =Label 这种方式,而不是直接的 B 指令,原因是 B 指令是相对跳转,范围有限,而 LDR PC 可以把任意 32 位绝对地址加载到 PC,在重定位时更可靠。
2.2 寄存器组的硬件分工,直接映射到你写的每一条汇编
手册里对通用寄存器的描述放在第二章之前,是为了让汇编实验有理论基础。Cortex-A7 在 USR/SYS 模式下有 R0-R14 共 15 个通用寄存器,加上 PC(R15)和 CPSR 一共 17 个。但每种异常模式都有独立的 R13(SP)、R14(LR)和 SPSR,这是硬件自动完成的寄存器组切换。也就是说,IRQ 模式的 LR 保存的是被中断打断的返回地址,SPSR_IRQ 保存的是被打断前的 CPSR 值。
R0-R3 在 ATPCS 调用约定里用于传参,R4-R11 保存局部变量,R12 是临时寄存器,R13 是栈指针,R14 保存子程序返回地址,R15 就是 PC。写裸机汇编时最容易翻车的就是在子函数里直接用了 R0-R3 和 LR,却没保存,导致返回地址被覆盖。手册里出栈入栈指令讲得比较简单,但实际工程中我一般会在每个子函数入口用 PUSH {R4-R11, LR},出口用 POP {R4-R11, PC},这条习惯能救很多次命。
CPSR 里的标志位也是绕不开的。N、Z、C、V 四个条件标志位会被比较指令和算术指令更新,后续的条件执行指令(EQ、NE、GT、LT 等)就是看这些标志位做判断。手册里 GPIO 方向配置和按键扫描的轮询逻辑都用到了条件执行,理解这一点,读代码才不会晕。
2.3 手册没细说但你必须知道的:SPSR 和异常返回机制
手册在寄存器组部分没有展开 SPSR 的细节,但这里恰恰是中断和异常处理最容易出 bug 的地方。每种异常模式有自己的 SPSR,硬件在进入异常时会自动把 CPSR 拷进 SPSR,返回时你用 MOVS PC, LR 就能把 SPSR 恢复到 CPSR,这条指令同时完成状态恢复和跳转。但是如果你在中断服务函数里嵌套调用了其他函数,LR 会被覆盖,所以第一件事就是把 LR 压栈。
还有一点,i.MX6ULL 的 GIC(通用中断控制器)把中断分为 IRQ 和 FIQ 两类,默认都走 IRQ。如果你在某段临界区关掉了 IRQ,却发现 FIQ 没关,那临界区就不完整。裸机阶段强制建议:临界区直接关掉 CPSR 的 I 和 F 位,别只关其中一个。
3. 第一个汇编点灯实验:GPIO、时钟和启动文件的全链路
3.1 看懂 i.MX6ULL 的 GPIO,先从 IOMUX 开始
手册第三章把 GPIO 分析拆成了几个层次:IO 引脚配置、GPIO 配置、GPIO 时钟。i.MX6ULL 的引脚都是复用引脚,要先把引脚复用为 GPIO 功能,再配置电气属性,最后才能操作 GPIO 数据寄存器。这三个步骤对应三个寄存器组:IOMUXC_SW_MUX_CTL_PAD_、IOMUXC_SW_PAD_CTL_PAD_、GPIO_DR / GPIO_GDIR。
手册里 LED 实验用的是 GPIO1_IO03 之类的引脚,MUX 寄存器的低两位设置复用模式,0x5 表示复用为 GPIO;PAD 寄存器主要配置上下拉、驱动力和速度。以下是我一般会写的 GPIO 初始化片段:
#define CCM_CCGR1 (0x020C406C) #define IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO03 (0x020E0068) #define IOMUXC_SW_PAD_CTL_PAD_GPIO1_IO03 (0x020E02E8) #define GPIO1_GDIR (0x020A4004) #define GPIO1_DR (0x020A4000) void led_gpio_init(void) { /* 使能 GPIO1 时钟,CCGR1 的 bit27:26 对应 GPIO1 */ *(volatile unsigned int *)CCM_CCGR1 |= (3 << 26); /* 复用为 GPIO 模式,MUX 寄存器低 2 位写 0x5 */ *(volatile unsigned int *)IOMUXC_SW_MUX_CTL_PAD_GPIO1_IO03 = 0x5; /* PAD 配置:使能上拉,驱动力中等 */ *(volatile unsigned int *)IOMUXC_SW_PAD_CTL_PAD_GPIO1_IO03 = 0x10B0; /* 设置为输出方向 */ *(volatile unsigned int *)GPIO1_GDIR |= (1 << 3); /* 默认输出高电平,LED 灭 */ *(volatile unsigned int *)GPIO1_DR |= (1 << 3); }这段代码的关键在于地址映射:CCM 时钟控制模块、IOMUXC 引脚复用模块、GPIO 模块都有各自独立的内存基地址,直接通过指针读写寄存器。CCM_CCGR1 里的 bit27:26 是 GPIO1 的时钟门控,写 2'b11 表示始终开启。MUX 寄存器写 0x5,对应 ALT5 模式即 GPIO 功能。PAD 寄存器 0x10B0 的具体含义是:bit15:14 速度中等、bit5:4 驱动力中等、bit3 使能上拉。如果你照着手册里的例程调,会发现 LED 不亮大概率就是 MUX 没写或者时钟没开。
3.2 汇编启动文件里的门道:栈指针和 .global 入口
手册 3.7 节编译 LED 汇编程序时,start.S 里有个必须注意的点:汇编入口必须用 .global _start 声明,否则链接时找不到入口。链接脚本或者 Makefile 里指定了 -Ttext 0x87800000,这是 i.MX6ULL 内部 RAM 的映射地址,如果你用的是 DDR 版本,地址要换成 DDR 的映射区域。
.global _start _start: /* 设置 SVC 模式栈指针,i.MX6ULL 内部 RAM 顶部 */ ldr sp, =0x87800000 /* 调用 C 代码中的 main 函数 */ bl main loop: b loop这段汇编只干了两件事:设置栈指针、跳进 C 的 main。关键在于栈地址的选择。0x87800000 是 i.MX6ULL 内部 SRAM 的起始地址,但栈是向下生长的,所以 sp 指向 SRAM 的最高地址,避免栈空间覆盖代码段。如果 sp 设太低,压栈数据会覆写启动代码自身,这是最常见的裸机翻车点。bl main 会先把返回地址存进 LR,再从 main 返回到 loop 死循环。
3.3 编译烧写验证:Makefile 里必须写的三件套
手册的编译流程分三件:arm-linux-gnueabihf-gcc 汇编编译、链接、二进制转换烧写。Makefile 里最核心的是链接地址和 objcopy 的 -O binary 输出:
all: arm-linux-gnueabihf-gcc -g -c start.S -o start.o arm-linux-gnueabihf-gcc -g -c main.c -o main.o arm-linux-gnueabihf-ld -Ttext 0x87800000 start.o main.o -o led.elf arm-linux-gnueabihf-objcopy -O binary led.elf led.bin arm-linux-gnueabihf-objdump -D led.elf > led.dis这里的 -Ttext 0x87800000 是链接地址,决定所有绝对跳转的目标地址。如果你用的下载工具要求 bin 文件从 0 地址开始烧写,那么 bin 的第一条指令必须能正确跳到 0x87800000 附近,否则一上电就跑飞。裸机工程的 bin 文件可以通过 SD 卡、USB 下载或仿真器烧写,手册里那个时代最常用的是 USB 下载工具配合 DCD 配置初始化 DDR。你烧写之前一定要确认开发板的启动拨码开关位置,拨错了板子根本没走 USB 下载流程。
4. 从汇编转到 C 语言实现:SDK 移植与工程目录设计的实战
4.1 为什么汇编点灯之后,紧接着就是 C 语言重写
汇编实验能让你看清寄存器的操作过程,但实际做项目不可能用汇编写业务逻辑。手册第四章 C 语言实现 LED 例程就是让你在同一个实验上换语言,理解 C 语言底层是怎么操作寄存器的。关键思路:用 volatile 指针直接映射寄存器地址,本质和汇编里的 ldr/str 没有区别。手写 C 版 LED 时,main 函数里就三件事:初始化 GPIO、循环翻转电平、延时。
void delay(volatile unsigned int n) { while (n--) {} } int main(void) { led_gpio_init(); while (1) { *(volatile unsigned int *)GPIO1_DR &= ~(1 << 3); // 输出低电平,LED 亮 delay(0x3FFFFF); *(volatile unsigned int *)GPIO1_DR |= (1 << 3); // 输出高电平,LED 灭 delay(0x3FFFFF); } return 0; }逻辑没有新东西,但值得留意的是 delay 函数。这个延时是 CPU 空转,不依赖任何硬件定时器,循环次数纯粹靠经验调。0x3FFFFF 在 528MHz 主频下大约能延时几百毫秒,具体数值还得看编译优化级别。我用 -O0 编译时这个延时够用,换成 -O2 循环可能会被优化掉一部分,LED 闪烁频率直接翻倍。所以做成产品前,这种空转延时必须替换成后面手册里的 GPT 定时器延时。
4.2 移植官方 SDK:把寄存器操作封装成外设库
手册第五章移植官方 SDK,这一步是从裸写寄存器到工程化开发的分水岭。官方 SDK 把底层寄存器访问封装成了结构体和函数,核心是IOMUXC_SetPinMux()和IOMUXC_SetPinConfig()这类 API,配合CLOCK_EnableClock()做时钟管理。你需要把 SDK 里的fsl_common.h、fsl_iomuxc.h、fsl_gpio.h、fsl_clock.h这些头文件组织进工程。
SDK 移植有个潜规则:不要一股脑把整个 SDK 的 source 目录全加进工程,编译会报一堆用不到的外设错误。我一般只拷贝需要的驱动文件,然后手动在fsl_clock.h里确认要用的外设时钟宏是否被定义。而且 SDK 默认的头文件路径是按官方开发板目录结构组织的,你换了开发板型号,头文件里的基地址宏要仔细核对,这在终结者开发板上问题不大,毕竟芯片是同一颗。
#include "fsl_iomuxc.h" #include "fsl_gpio.h" #include "fsl_clock.h" void led_sdk_init(void) { gpio_pin_config_t led_config = {kGPIO_DigitalOutput, 0}; CLOCK_EnableClock(kCLOCK_Iomuxc); IOMUXC_SetPinMux(IOMUXC_GPIO1_IO03_GPIO1_IO03, 0); IOMUXC_SetPinConfig(IOMUXC_GPIO1_IO03_GPIO1_IO03, 0x10B0); GPIO_PinInit(GPIO1, 3, &led_config); }SDK 的 API 好处是代码可读性大幅提升,kGPIO_DigitalOutput 是枚举类型,0 表示初始输出低电平。IOMUXC_SetPinMux 的第二个参数是输入使能,GPIO 作为输出时写 0 即可。如果你后面做按键读取,这个参数要改成 1,否则引脚输入通路是断的,读到的电平永远是 0。这是移植 SDK 时最容易被忽略的细节。
4.3 裸机工程目录结构:头文件路径和链接脚本的坑
手册第六章讲创建裸机工程,提到了目录结构,但没强调链接脚本的作用。到这一步,工程里有了 start.S、多个 C 源文件、SDK 头文件,继续用-Ttext指定链接地址就不好维护了,尤其是要配置中断向量表、只读数据段、数据段加载地址的时候。这时候你需要自己写链接脚本:
SECTIONS { . = 0x87800000; .text : { *(.text) } .rodata : { *(.rodata) } .data : { *(.data) } .bss : { __bss_start = .; *(.bss) __bss_end = .; } }裸机启动代码里必须有清 BSS 段的逻辑。BSS 段存放未初始化或初始化为 0 的全局变量,这块内存在程序里没有实际内容,上电后内容随机,不清零的话全局变量初始值就是乱的。你可以在 start.S 里用ldr r0, =__bss_start、ldr r1, =__bss_end,循环写零。
4.4 手册没写透的中断向量表重定位
手册第十章 GPIO 中断例程前面讲了一堆中断系统原理:中断向量表、GIC 控制器、中断优先级,这些理论直接决定了你中断代码能不能跑起来。i.MX6ULL 的 GIC 分两组:GIC distributor 和 CPU interface,distributor 负责管理所有中断源、优先级和使能,CPU interface 负责向 CPU 核上报中断。你要做的就是把外设中断号使能,然后在 IRQ handler 里读取 GICC_IAR 寄存器拿到中断 ID,处理完写 GICC_EOIR。
void default_irq_handler(void) { unsigned int irq_id = GICGetPendingIRQ(); if (irq_id == GPIO1_Combined_0_15_IRQn) { if (GPIO_PortGetIntFlag(GPIO1, 1 << 3)) { GPIO_PortClearIntFlags(GPIO1, 1 << 3); /* 处理按键中断逻辑 */ } } GICClearPendingIRQ(irq_id); }GICGetPendingIRQ 读的是 GICC_IAR,这个寄存器必须在处理完中断后写 GICC_EOIR,表示中断结束。漏写 EOIR 的后果是:同一个中断号永远挂在 pending 状态,中断处理函数反复进入,系统看起来像死机。GPIO_PortGetIntFlag 读的是 GPIO_ISR,同时要用 GPIO_ICR 配置边沿触发方式,中断标志必须软件清除,硬件不会自动清。
4.5 避坑:裸机开发最常见的五个翻车现场
现象一:LED 不亮,程序编译烧写都正常。原因多半是 IOMUX 复用没配置成 GPIO 模式,或者 GPIO 时钟没使能。解决:回查 IOMUXC_SW_MUX_CTL_PAD 寄存器值是否为 0x5,确认 CCM_CCGR1 对应 bit 是否为 1,用调试器读寄存器实测,别猜。
现象二:程序下载成功,但板子跑的不是新程序。原因大概率是启动拨码开关选错启动设备,或者 bin 烧到了错误地址。解决:核对拨码,用下载工具的校验功能确认 bin 内容写入的目标地址和链接地址一致。
现象三:汇编跳转 C 代码后死机。原因极可能是栈指针没设置或者设错地址,C 语言函数调用的第一件事就是压栈。解决:单步调试,看 sp 是否指向有效 RAM,排除 DDR 初始化未完成导致的内存不可访问。
现象四:中断服务函数反复进入。原因要么是 GPIO_ISR 中断标志没清除,要么是 GICC_EOIR 没写。解决:在 handler 开头先读中断 ID,处理完依次清外设标志和 GIC 结束标志。
现象五:SDK 编译报错,说什么宏未定义。原因多数是头文件搜索路径不完整,或者芯片型号宏定义不对。解决:Makefile 里加-I指向 SDK 的 include 路径,检查编译器选项里的-DCPU_MX6ULL是否加上。
5. 时钟、定时器和延时:从跑马灯到可靠的时间基准
5.1 主频和时钟配置:为什么不能直接相信默认频率
手册第九章讲了 i.MX6ULL 时钟系统。芯片上电后默认走的是 24MHz 的振荡器,内部 PLL 还没锁定,主频跑在低频率。你要提升性能,就得通过 CCM 模块配置 ARM PLL、外设 PLL 和分频器。裸机例程里最常见的配置是把 ARM PLL 倍频到 528MHz,但这里有个次序问题:必须先等 PLL 锁定,再切换时钟源,最后调整分频。直接写倍频值会导致系统时钟不稳,程序偶发跑飞。
配置时钟的代码通常是查手册的时钟树表,找到 PLL1 的配置寄存器,写倍频因子和分频系数,然后设置 CLOCK_SWITCH 寄存器把 CPU 时钟源切换到 PLL1。动手前一定先算出当前外设(UART、SD、GPIO)的时钟会不会被这次切换影响,如果你把某个外设的时钟源切走了,那个外设就罢工了。
5.2 EPIT 定时器:轮询之外的另一种按键扫描方案
手册第十一章 EPIT 定时器例程、第十二章定时器按键例程,是两章连读的组合。EPIT 是一个 12 位递减计数器,计数到 0 触发中断,中断里做按键扫描,替代主循环里的轮询。这个设计思路比裸奔的 while 轮询先进:主循环可以干别的,定时器中断保证扫描周期稳定。
EPIT 配置的核心参数是计数源、分频、装载值和重载模式。EPIT 的时钟源可以选高频参考时钟,分频后得到计数频率,装载值决定中断周期。假如你想 10ms 扫描一次按键,24MHz 时钟经过 24 分频得 1MHz,装载值写 10000 就是 10ms。这个参数组合完全由你手里的晶振频率和使用场景决定,不能照抄。
void epit_init(unsigned int loadvalue) { EPIT->CR = 0; EPIT->CR |= (1 << 24) | (1 << 26) | (0 << 24); EPIT->CR &= ~(1 << 24); EPIT->LR = loadvalue; EPIT->CMPR = 0; EPIT->CR |= (1 << 24) | (1 << 25) | (1 << 26); }这段配置里第 24 位是 EN 使能位,第 25 位是 OCIEN 中断使能,第 26 位是 RLD 重载使能。CR 的低 2 位选时钟源,CLKSRC 写 0 选 IPG 时钟。初始化顺序是:先关定时器、清配置、写装载值、写比较值,最后再使能。EPIT 的比较寄存器 CMPR 写 0 表示计数器减到 0 就触发,不要写成装载值,否则中断周期直接翻倍。
5.3 GPT 定时器延时:替换掉 CPU 空转的延时函数
手册第十三章 GPT 定时器延时例程,把前面 delay 空转函数的缺点彻底解决。GPT 是一个 32 位向上计数器,精度高、范围大。用它做延时,思路是:启动 GPT 计数,记录开始时的计数值,然后不断读取当前值,直到差值达到目标延时。这样 CPU 在等待期间不能干别的,但延时长度不依赖主频和优化等级,时间准确。
void gpt_delay_ms(unsigned int ms) { unsigned int start_cnt, cur_cnt; start_cnt = GPT->CNT; while (1) { cur_cnt = GPT->CNT; if ((cur_cnt - start_cnt) >= (ms * 1000)) break; } }这里有个无符号整数减法的技巧:(cur_cnt - start_cnt)即使 GPT 计数器回绕,结果依然是正确的计数值差。GPT 的时钟需要先初始化,一般选 24MHz 晶振源做 24 分频得 1MHz,那么 1ms 就是 1000 个计数。这个函数用在按键消抖、LED 闪烁、传感器读取超时判断上都很可靠,是我的裸机工程标配工具函数。
5.4 进阶:把定时器中断和服务函数解耦,形成小型调度框架
如果你这十几个裸机例程都跑通了,我建议做一次融合:把 EPIT 定时器中断做成 1ms 的 tick,在中断里维护一个全局 tick 计数器,主循环里通过查询 tick 差值实现非阻塞延时。这本质上是小型时间片调度。具体做法是:
volatile unsigned int g_tick_ms; void epit_irq_handler(void) { if (EPIT->SR & (1 << 0)) { EPIT->SR |= (1 << 0); g_tick_ms++; } } void delay_nonblock(unsigned int wait_ms, unsigned int *last_tick) { while ((g_tick_ms - *last_tick) < wait_ms) { } }这个框架的价值在于:你可以在主循环里同时跑 LED 闪烁、按键扫描、串口发送三个任务,互不阻塞。这也是从裸机过渡到 RTOS 的必经之路。我最初做裸机项目时,总是把所有逻辑塞进一个大 while 循环,改一个功能就得重新理顺全局流程,后来强制自己用 tick 机制拆任务,代码结构才清晰起来。
最后提一个我一直以来的习惯:每次烧写完程序,不要急着看现象,先用串口或者调试器把关键寄存器读出来核对一遍,时钟频率、GPIO 方向、中断状态,确认硬件配置和代码意图一致,再去验证功能。这个习惯帮我省了大量定位问题的时间。希望这份手册的拆解能帮你在裸机开发这条路上少踩几个坑。
本文还有配套的精品资源,点击获取