简介:这是一套面向嵌入式初学者与高校实验教学的ARM基础实验资源包,围绕ARM体系结构与汇编程序设计展开,共收录18个经典实验,适合刚接触嵌入式、汇编基础尚浅的同学动手练习,也可供进阶学习者回顾核心实验。压缩包为rar格式,内含360个文件,总计约10.58MB,其中99个c源文件与97个h头文件构成实验主体代码,27个s汇编文件对应底层汇编实验,另有46个o目标文件、10个axf可执行文件及9个pdf说明文档,配合mcp、tdt等工程配置文件,覆盖从源码到可执行结果的完整链路。资源附有PDF详细说明,代码后均带注释讲解,便于对照理解每条指令与寄存器操作。内容涉及GPIO控制、中断、串口通信、步进与直流电机、EEPROM读写、按键扫描等典型实验场景,能帮助读者建立从汇编到C的嵌入式开发认知。目前已有143人学习,适合作为课程实验与自学练手的参考材料。
1. 从一份“裸机”实验包说起:ARM 嵌入式到底在练什么
很多人第一次拿到 ARM 嵌入式实验包,以为里面是几个现成的工程,点开就能跑。结果解压一看,全是.s汇编启动文件、.c驱动源码、.ld链接脚本,连个像样的 IDE 工程都没有。这份 ARM 基础实验资源就是这种“原汁原味”的裸机代码集合,它不依赖任何操作系统,直接跑在芯片的 SRAM 或 Flash 上,用最朴素的方式把 ARM 架构的核心机制摊开给你看。它适合两类人:一类是刚学完单片机、想真正搞懂“C 代码怎么变成机器指令”的嵌入式新手;另一类是做久了应用层、想回头补一补启动流程和异常处理的老手。你拿到它,练的不是调库,而是从复位向量到main函数之间那段平时被 IDE 藏起来的黑匣子。
2. 工程结构拆解:启动文件、链接脚本与 Makefile 怎么配合
2.1 目录布局与文件职责
这份实验包通常按“公共头文件 + 各实验独立目录”来组织。公共部分放common/或inc/,里面是寄存器地址映射、类型定义和延时函数声明;每个实验一个文件夹,比如led_blink/、uart_echo/、interrupt_demo/,各自包含startup.s、main.c、system.c和Makefile。这种结构的好处是每个实验都能独立编译,互不干扰,你改坏了一个不影响其他。
常见做法是把芯片厂商提供的启动文件稍作裁剪,只保留复位处理、中断向量表和栈初始化。链接脚本linker.ld决定代码段、数据段和 BSS 段分别落在哪块内存,比如 Flash 起始地址0x08000000,SRAM 起始地址0x20000000。Makefile 则负责调用交叉编译工具链,把.s和.c编译成.o,再链接成.elf,最后用objcopy转出.bin或.hex。
提示:不同芯片的 Flash 和 SRAM 地址不一样,拿到包先看链接脚本里的
MEMORY块,确认和你手头的板子对得上。
2.2 启动文件里的复位流程
启动文件是整份资源里最值得逐行读的部分。它用汇编写成,干的事很固定:设置栈指针、初始化时钟、拷贝数据段、清零 BSS 段、跳转到main。下面这段是典型的复位处理片段,我加了注释方便对照。
Reset_Handler: LDR R0, =_estack ; 把栈顶地址加载到 R0 MOV SP, R0 ; 设置主栈指针 LDR R0, =_sdata ; 数据段在 Flash 中的起始地址 LDR R1, =_edata ; 数据段在 SRAM 中的结束地址 LDR R2, =_sidata ; 数据段在 Flash 中的加载地址 CopyData: CMP R0, R1 ; 判断是否拷贝完 ITTT LT LDRLT R3, [R2], #4 ; 从 Flash 读一个字 STRLT R3, [R0], #4 ; 写入 SRAM BLT CopyData LDR R0, =_sbss ; BSS 段起始 LDR R1, =_ebss ; BSS 段结束 MOV R2, #0 ZeroBss: CMP R0, R1 ITTT LT STRLT R2, [R0], #4 ; BSS 段清零 BLT ZeroBss BL SystemInit ; 调用系统时钟初始化 BL main ; 进入 C 世界这段代码里_estack、_sdata、_edata、_sidata、_sbss、_ebss都不是随便起的名字,它们必须在链接脚本里用PROVIDE或直接定义出来,否则链接阶段就会报未定义符号。SystemInit一般用 C 写,负责配置 PLL、总线分频和 Flash 等待周期。BL main执行完不会返回,所以后面通常跟一个死循环。
2.3 链接脚本与 Makefile 的联动
链接脚本决定内存布局,Makefile 决定编译顺序。一个精简的链接脚本长这样:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 256K SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K } SECTIONS { .text : { *(.isr_vector) *(.text*) } > FLASH .data : { *(.data*) } > SRAM AT> FLASH .bss : { *(.bss*) } > SRAM }.isr_vector必须放在最前面,因为复位后硬件固定从0x08000000取栈顶值和复位向量。.data段用AT> FLASH表示加载地址在 Flash、运行地址在 SRAM,启动文件里的拷贝循环就是干这个的。Makefile 里一般会定义CROSS_COMPILE = arm-none-eabi-,然后CC = $(CROSS_COMPILE)gcc,编译选项带-mcpu=cortex-m3 -mthumb -Og -g。改芯片型号时,-mcpu和链接脚本的MEMORY要一起改,只改一个必翻车。
3. 核心模块实战:GPIO、UART 与中断的 C 代码怎么写
3.1 GPIO 点灯:从寄存器到宏封装
裸机点灯不是调HAL_GPIO_WritePin,而是直接操作寄存器。以常见的 Cortex-M 为例,使能 GPIO 时钟要写RCC->AHB1ENR,配置模式要写GPIOx->MODER,输出电平写GPIOx->ODR。下面是一个可复用的宏封装:
#define RCC_BASE 0x40023800UL #define GPIOA_BASE 0x40020000UL #define RCC_AHB1ENR (*(volatile uint32_t *)(RCC_BASE + 0x30)) #define GPIOA_MODER (*(volatile uint32_t *)(GPIOA_BASE + 0x00)) #define GPIOA_ODR (*(volatile uint32_t *)(GPIOA_BASE + 0x14)) void led_init(void) { RCC_AHB1ENR |= (1 << 0); // 使能 GPIOA 时钟 GPIOA_MODER &= ~(3 << 10); // 清 PA5 模式位 GPIOA_MODER |= (1 << 10); // PA5 设为通用输出 } void led_toggle(void) { GPIOA_ODR ^= (1 << 5); // 翻转 PA5 电平 }volatile不能省,否则编译器优化时可能把连续的寄存器读写合并掉。RCC_AHB1ENR的位 0 对应 GPIOA,位 1 对应 GPIOB,具体查芯片参考手册。MODER每两位控制一个引脚,01是输出,10是复用功能,11是模拟。点灯实验虽然简单,但它是验证工具链、时钟和下载流程是否全通的最小闭环,跑通它再往下做才有意义。
3.2 UART 收发:轮询与中断两种写法
UART 是嵌入式调试的命根子。轮询方式写起来直白,但会阻塞 CPU;中断方式效率高,但要多写一个中断服务函数。先看轮询发送一个字节:
#define USART1_BASE 0x40011000UL #define USART1_SR (*(volatile uint32_t *)(USART1_BASE + 0x00)) #define USART1_DR (*(volatile uint32_t *)(USART1_BASE + 0x04)) void uart_putc(char c) { while (!(USART1_SR & (1 << 7))); // 等待 TXE 置位 USART1_DR = c; // 写入数据寄存器 } void uart_puts(const char *s) { while (*s) uart_putc(*s++); }SR寄存器的位 7 是 TXE,表示发送数据寄存器空。位 5 是 RXNE,表示收到数据。轮询接收就是死等 RXNE,然后读DR。中断方式则要在 NVIC 里使能USART1_IRQn,并在启动文件的中断向量表里把USART1_IRQHandler填进去。中断服务函数里先判断是 RXNE 还是 TXE,再决定读还是写,最后别忘了清标志。常见坑是忘了在 NVIC 里使能中断,或者向量表里的名字和 C 函数名对不上,导致一进中断就跳飞到默认处理函数。
3.3 中断与异常处理:向量表重定位
Cortex-M 的向量表默认在0x00000000,但实际芯片往往把 Flash 映射到0x08000000,所以启动文件里会通过SCB->VTOR重定位向量表。下面这段在SystemInit里很常见:
#define SCB_VTOR (*(volatile uint32_t *)0xE000ED08) void SystemInit(void) { SCB_VTOR = 0x08000000; // 向量表重定位到 Flash 起始 // 其他时钟配置... }如果做 Bootloader 加 App 的方案,App 的向量表要重定位到自己的起始地址,比如0x08008000。重定位之后,所有中断都会从新表里取入口地址。这里有个血泪经验:重定位之前必须先关全局中断,配好之后再开,否则中间来一个中断就可能取到旧表里的错误地址。另外,栈指针在复位时由硬件从向量表第一个字加载,所以向量表第一个字必须是合法的栈顶地址,不能是函数指针。
4. 编译、烧录与调试:把 .elf 变成板子上的灯
4.1 工具链安装与 Makefile 关键变量
交叉编译工具链一般用arm-none-eabi-gcc。在 Linux 下可以用包管理器装,Windows 下装完记得把bin目录加进 PATH。验证是否装好:
arm-none-eabi-gcc --versionMakefile 里几个关键变量要按实际情况改:
CROSS_COMPILE = arm-none-eabi- CC = $(CROSS_COMPILE)gcc OBJCOPY = $(CROSS_COMPILE)objcopy CFLAGS = -mcpu=cortex-m3 -mthumb -Og -g -Wall -Iinc LDFLAGS = -Tlinker.ld -nostartfiles-nostartfiles表示不用工具链自带的启动文件,用我们自己的startup.s。-Tlinker.ld指定链接脚本。-Og是调试友好的优化级别,比-O0生成的代码小,又不像-O2那样把变量优化没。如果编译报undefined reference to _estack,说明链接脚本里没定义这个符号,补上PROVIDE(_estack = ORIGIN(SRAM) + LENGTH(SRAM));即可。
4.2 烧录方式:OpenOCD 与串口 ISP
烧录通常有两条路:调试器烧录和串口 ISP。调试器方案用 OpenOCD 加 GDB,或者用厂商提供的命令行工具。以 OpenOCD 为例,先起服务:
openocd -f interface/stlink.cfg -f target/stm32f1x.cfg另开一个终端连 GDB:
arm-none-eabi-gdb build/demo.elf (gdb) target remote :3333 (gdb) load (gdb) monitor reset halt (gdb) continue串口 ISP 则要先把 BOOT 引脚拉到系统存储器启动模式,然后用厂商的下载工具通过 UART 发数据。这种方式不需要调试器,但每次烧录要手动改跳线,调试也不方便。我一般先用 ISP 确认芯片是活的,再切到调试器做单步。
4.3 用 GDB 看反汇编与寄存器
调试裸机代码时,GDB 的info registers和disassemble比打印日志还直接。比如程序跑飞了,先monitor reset halt停住,然后stepi单步执行汇编,看 PC 跳到哪去了。如果发现一进中断就跳到Default_Handler,多半是向量表名字对不上或者中断没使能。x/10i $pc可以看当前指令附近的反汇编,p/x $sp看栈指针有没有越界。这些命令在 IDE 里被藏得很深,但命令行下反而更快。
5. 避坑与排查:裸机开发最容易翻车的五个点
5.1 程序下载后不运行,复位就停
现象:烧录成功,但板子没反应,调试器里看 PC 停在0xFFFFFFFE或某个默认处理函数。原因通常是向量表第一个字不是合法栈顶地址,或者链接脚本把.isr_vector放到了错误位置。解决:用objdump -s -j .isr_vector demo.elf看前 8 个字节,第一个字应该是 SRAM 末尾地址,第二个字是Reset_Handler地址。不对就检查链接脚本的SECTIONS顺序。
5.2 全局变量初值不对,全是 0 或随机值
现象:int flag = 5;在main里读出来不是 5。原因:.data段拷贝循环没执行,或者链接脚本没把.data的加载地址和运行地址分开。解决:确认启动文件里CopyData循环存在,且链接脚本里.data段写了AT> FLASH。用arm-none-eabi-objdump -h demo.elf看.data的 VMA 和 LMA 是否不同。
5.3 中断进不去,或者进去一次就死
现象:串口收到一个字节后再也没反应。原因:中断服务函数里没清标志位,导致中断反复触发;或者 NVIC 优先级配置错误。解决:在USART1_IRQHandler里先读SR再读DR,这两个动作会清 RXNE 标志。如果是定时器中断,要写TIMx->SR = 0清更新标志。清标志的代码要放在中断处理的最前面或最后面,别放在中间被return跳过。
5.4 栈溢出,局部数组一大就死机
现象:函数里定义char buf[1024]后程序跑飞。原因:默认栈大小在链接脚本或启动文件里设得太小,比如只有 1KB。解决:在链接脚本里单独划一个.stack段,或者改_estack和_sstack的定义,把栈空间放大到 4KB 以上。调试时可以在栈顶和栈底填魔数,跑一段时间后看魔数有没有被覆盖。
5.5 优化等级一高,延时循环被优化没
现象:-O0下正常的软件延时,改成-O2后延时变得极短。原因:编译器认为空循环没有副作用,直接删掉了。解决:把延时循环里的变量加volatile,或者用__NOP()内联汇编。更稳妥的做法是用硬件定时器做延时,不跟编译器较劲。
6. 二次开发改造:从实验包到自己的板级支持包
拿到这份实验包,最终目的是把它变成你自己项目的起点。我的习惯是先做三件事:把公共头文件里的寄存器地址抽成独立文件,按外设分文件管理;把启动文件和链接脚本做成模板,换芯片时只改MEMORY和-mcpu;把 Makefile 里的源文件列表改成自动扫描,省得每加一个.c就手动改一次。
具体改造时,我会新建一个bsp/目录,里面放bsp_gpio.c、bsp_uart.c、bsp_timer.c,每个文件只暴露xxx_init和xxx_read/write接口,寄存器操作全藏在.c里。这样上层应用代码不直接碰寄存器,换芯片时只改bsp/下的实现。下面是一个改造后的目录示例:
project/ ├── bsp/ │ ├── bsp_gpio.c │ ├── bsp_uart.c │ └── bsp_timer.c ├── inc/ │ ├── bsp_gpio.h │ ├── bsp_uart.h │ └── reg_map.h ├── startup/ │ ├── startup.s │ └── linker.ld ├── app/ │ └── main.c └── Makefile验证改造是否成功,我一般跑一个最小测试:用bsp_uart打印一行字符串,用bsp_timer每 500ms 翻转一个 LED,用bsp_gpio读一个按键状态。三个都正常,说明 BSP 层抽象没破坏底层功能。然后再把中断处理也收进 BSP,比如bsp_uart提供uart_register_rx_callback,让应用层注册回调而不是直接写中断服务函数。这样中断向量表里只留一个统一的USART1_IRQHandler,里面根据回调指针分发。
改造过程中最容易犯的错是过度抽象。我见过有人把 GPIO 封装成gpio_set_pin_level(pin, level)之后,又在上面套一层led_on()、led_off()、led_toggle(),结果调个灯要穿四层函数。裸机资源本来就紧,函数调用开销虽然不大,但代码可读性反而下降。我的原则是:BSP 层只做寄存器到语义的映射,不再往上加业务逻辑。业务逻辑放app/里,想怎么组合都行。
还有一个实用技巧是给链接脚本加一个._user_heap_stack段,用ASSERT检查栈和堆有没有重叠。比如:
._user_heap_stack : { . = ALIGN(8); PROVIDE(_sstack = .); . = . + 0x1000; /* 4KB 栈 */ PROVIDE(_estack = .); } > SRAM ASSERT(_estack <= ORIGIN(SRAM) + LENGTH(SRAM), "Stack overflow!")这样链接阶段就能发现栈越界,不用等到运行时死机。从那以后我每次换芯片或改链接脚本,都强制走一遍objdump -h看段布局,再跑一遍最小测试三件套,确认没问题才往上加功能。希望帮到你。
本文还有配套的精品资源,点击获取