从点灯实验到寄存器操作:嵌入式ARM裸机开发完整链路解析
做嵌入式这几年我有个改不掉的习惯不管拿到什么开发板第一件事从来不是跑什么大系统、调什么高级接口而是先点亮一颗LED。这个习惯就是从一块 JZ2440 开发板上的裸机实验养成的。所谓裸机实验就是在没有操作系统的情况下直接操作芯片寄存器让硬件按照你的意图动作。点灯这个动作看起来不过是往某个引脚写一个低电平但背后牵扯到芯片手册怎么读、GPIO 怎么配置、交叉编译工具链怎么搭、编译出来的二进制怎么烧进去一条完整的嵌入式开发链路全在里头。这篇笔记把我做这个实验时踩过的坑、想明白的道理一起写出来适合刚接触 ARM 裸机开发的朋友也适合那些平时只会对着开发框架调接口、没自己亲手配过寄存器的开发者补上底层这一课。1. 先搞懂这个实验到底在验证什么1.1 为什么嵌入式第一课都是点灯很多初学者不理解为什么入门教材不先讲 UART、不讲中断偏偏选一颗最不起眼的LED。我刚开始也觉得这事儿有点low等自己完整走了一遍才明白点灯是投入产出比最高的一课。LED 实验对硬件外设的依赖最小基本只需要芯片上的一根 GPIO 引脚。这点非常关键因为你在学一个全新系统的时候最怕的就是多个外设互相牵扯出了问题根本不知道是哪里出错。而点灯只需要验证一件事CPU 有没有跑起来代码有没有执行到指定位置。它同时验证了整条开发链路是否打通。从交叉编译工具链到编译、链接、生成二进制再到通过调试器把程序下载到板子最后在板子上运行——任何一个环节断了LED 都不会亮。所以点灯实验真正的价值不在灯亮了这个结果而在于它把工具链、烧写流程、硬件原理全部串起来走了一遍。灯一亮意味着你后续做串口、做中断、做时钟都站在一个验证过的可靠基础上。1.2 裸机开发到底和 Linux 驱动差在哪说到裸机开发很多人会把它和 Linux 驱动搞混以为是同一件事。实际上两者操作的是同一批硬件寄存器但工作方式完全不同。裸机程序里main 函数就是整个世界没有进程调度没有内核框架所有寄存器地址都是你直接写进代码里的。芯片复位后CPU 从指定地址取第一条指令一路顺序执行下去跑的就是你写的这一份代码。Linux 驱动则是在内核已经跑起来之后驱动模块在操作系统框架里注册、匹配设备树通过 ioremap 把物理地址映射成虚拟地址再去操作。底层还是那组寄存器但上面套了一层又一层。我经常用一个类比裸机开发就像你直接去拧水管的总阀拧多大、什么时候拧全是自己说了算Linux 驱动则像通过一套物业管理系统去开关水阀你只管调用接口不用关心管线怎么走。但问题在于如果连总阀在哪、怎么拧都不知道你写的接口调用永远是背代码而不是写代码。这也是我强烈建议学嵌入式的人先花点时间做裸机实验的原因。1.3 开工之前要备齐的东西这个实验需要的硬件其实很少但每一样都有明确的用途我列一下一块 S3C2440 的开发板JZ2440 只是这类板子中比较常见的一款其他同芯片的板子原理一样。电源和 USB 转串口线。串口不是点灯必需的但后面调试、看打印信息必不可少建议一开始就接好。一个 JTAG 调试器。用来把编译好的 bin 文件加载到板子的内存里运行。没有它你就只能靠焊台烧 Flash效率低很多。最关键的两份文档芯片数据手册Datasheet和开发板原理图。寄存器地址、位定义、LED 接在哪个引脚全看这两份东西。网上很多教程直接告诉你写 0x56000050但如果不养成查手册的习惯换个芯片你就完全抓瞎。软件方面一台装了 Linux 的电脑就够了虚拟机也行。工具链的事后面单独讲这里先记住一点不要在 Windows 上编译 ARM 裸机程序交叉工具链在 Linux 下最顺。2. 硬件原理点亮LED真正操作的是寄存器2.1 认识 S3C2440 的 GPIO 结构S3C2440 这颗芯片的引脚很多但芯片厂家为了方便管理把引脚按端口分成 A 到 J 若干组每组下面又有若干引脚比如 GPF4、GPF5、GPG0 这种名字前面的 G 代表 GPIO中间的字母是端口名后面的数字是组内第几号引脚。每个引脚都不是只有一个功能。比如某个引脚既可以做普通的输入输出也可以复用成串口发送脚、I2C 数据脚全看你怎么配置。这就是芯片引脚的多功能复用设计。你要想控制 LED首先要做的不是写数据而是告诉芯片这个引脚我要拿它当普通输出用。这个告诉芯片的动作就是配置寄存器。我把每组 GPIO 的控制方式总结成一句话一组引脚三个核心寄存器一个管方向一个管数据一个管上下拉。方向不对后面写再多数据都是白搭。这也是裸机开发最基本的心智模型。2.2 三个关键寄存器CON、DAT、UP以本实验用到的 GPF 端口为例三个寄存器的地址是固定的寄存器地址作用GPFCON0x56000050配置每个引脚的功能方向输入/输出/复用GPFDAT0x56000054读写引脚的电平状态GPFUP0x56000058控制每个引脚内部上拉电阻的开关GPFCON 寄存器里每个引脚分到 2 个比特位。GPF4 对应的就是第 9 位和第 8 位。这 2 个比特位写什么决定引脚是什么模式00 是输入01 是输出10 是复用功能11 在中端部分引脚上是外部中断。本实验想让 GPF4 当普通输出脚就要把这 2 位设成 01。具体操作时我习惯先清除再置位防止破坏其他引脚已有的配置GPFCON ~(3 8); // 把第9、8位清0 GPFCON | (1 8); // 把第8位置1得到01即输出模式GPFDAT 寄存器更简单每一位对应一个引脚的电平。想输出高电平就写 1输出低电平就写 0。GPFUP 是内部上拉控制置 1 表示禁止内部上拉置 0 表示使能。这个实验里它对点灯结果影响不大但你在读按键时会遇到先记着有这个寄存器存在。2.3 电路图与低电平点亮的来由翻开开发板原理图你会看到 LED 那一页画得很直白电源 VCC 经过一颗限流电阻接到 LED 的正极LED 的负极接到芯片的某个 GPIO 引脚。也就是说引脚输出低电平时LED 两端形成压差电流流过 LED灯就亮了引脚输出高电平时LED 两端等电位没有电流灯灭。这就是常说的低电平点亮。写程序的时候千万别搞反——往 GPFDAT 里写 1 灭灯、写 0 亮灯是这类电路最常见的行为。关于限流电阻你可以自己算一下LED 导通压降通常在 2V 左右工作电流取 5~10mA电源是 3.3V那限流电阻大约就是 (3.3 - 2) / 0.01 130Ω所以板子上放个 330Ω、470Ω、1kΩ 都很常见。这个数值不需要你改按原理图来就行。但知道这个计算过程是有价值的以后你自己画板子、或者改别人电路时能快速判断一颗 LED 会不会烧。2.4 别忘了先关看门狗这是裸机新手最容易踩的暗坑。S3C2440 芯片上有个看门狗定时器地址是 0x53000000。看门狗的作用是如果程序跑飞了它会在一定时间后强制复位整个系统相当于一个保安。问题在于芯片复位后看门狗默认是开启状态。也就是说你写的裸机程序如果没有主动去关它系统运行一小段时间后就会被看门狗复位然后重新启动再被复位如此反复。我最早做这个实验就遇到过诡异现象LED 亮大约一秒钟灭一下又亮节奏完全不受我控制。排查了半天才发现是看门狗在搞鬼。所以裸机代码的第一件事基本都一样WTCON 0; // 关闭看门狗这一行写不写在点灯这种延时很短的程序里也许还能忍但只要你后面做稍微复杂一点的逻辑它就会咬你一口。养成习惯每个裸机工程的入口先关看门狗。3. 交叉编译环境与Makefile怎么搭3.1 为什么不能在 PC 上直接 gcc 编译你可能会有个疑问我在电脑上写 C 代码平时用 gcc 编译得好好的为什么到了嵌入式就得用一套叫交叉编译的东西原因在于 CPU 指令集不同。PC 的 CPU 是 x86 架构S3C2440 是 ARM 架构两者认识的机器码完全不一样。你用普通 gcc 编出来的程序拿到 ARM 板子上根本执行不了。交叉编译的意思就是在一台机器上编译出另一种架构能运行的二进制。编译环境是 x86编译目标却是 ARM所以叫交叉。解决这个问题不难安装一套以 arm-linux- 为前缀的工具链就行。前缀的作用是把编译器、汇编器、链接器、二进制转换工具全部换血。3.2 工具链的安装与验证工具链的安装方式视你拿到的版本而定本质都是解压后把 bin 目录加进 PATH。我以前常用的做法是解压到 /opt 下sudo tar xjf cross-tools.tar.bz2 -C /opt export PATH/opt/toolchain/bin:$PATH这个 export 只对当前终端有效想让每次开终端都生效就把它写进 ~/.bashrc。配置好之后务必验证一下arm-linux-gcc -v能正常打印版本信息说明工具链可用。这里有一个我踩过的坑老版本的工具链是 32 位的装到 64 位 Linux 上会报cannot execute binary file之类错误原因是没有 32 位运行库。解决方法是在系统里补装 32 位兼容库具体包名各发行版不太一样碰到时搜索一下即可。这个问题很有迷惑性因为它长得像工具链坏了实际只是缺系统库。3.3 从 .c 到 .bin 的完整链路一个 C 裸机程序要变成能烧到板子上的 led.bin中间要经历三步缺一不可第一步用 arm-linux-gcc 把汇编文件和 C 文件分别编译成目标文件 .o。第二步用 arm-linux-ld 把这些 .o 文件链接成可执行文件这一步要指定链接地址也就是告诉编译器我的代码将来要运行在哪个内存地址。第三步用 arm-linux-objcopy 把链接出来的 ELF 文件转成纯粹的二进制 bin 文件因为芯片不认 ELF只认识裸的机器码。三步的依赖关系用 Makefile 管起来最方便CROSS arm-linux- CC $(CROSS)gcc LD $(CROSS)ld OBJCOPY $(CROSS)objcopy led.bin: start.o main.o $(LD) -Ttext 0x30000000 -o led.elf start.o main.o $(OBJCOPY) -O binary led.elf led.bin start.o: start.S $(CC) -c -o start.o start.S main.o: main.c $(CC) -c -o main.o main.c clean: rm -f *.o *.elf *.bin注意Makefile 里命令行的缩进必须是 Tab 键不能是空格。这个问题我第一次写就栽了报错信息还特别不友好。另外这里的-Ttext 0x30000000不是随便填的它和后面讲的运行方式强相关先说结论如果把程序加载到 SDRAM 里跑运行地址是 0x30000000链接地址就必须是 0x30000000如果要从 NOR Flash 启动链接地址就得改成 0x0。链接地址和实际运行地址不一致程序必挂这个知识点后面会反复用到。4. 代码实现汇编入口加上C语言主逻辑4.1 start.S把关看门狗、设栈、进 main 三件事做干净裸机程序需要一个汇编入口做三件 C 语言不方便做的事关看门狗、初始化栈指针、跳进 C 语言的 main。为什么不能直接让 C 的 main 作为入口因为 C 语言运行需要栈而复位之后栈指针 sp 是随机值在没初始化栈的情况下调用任何函数都会导致崩溃。这是一个最精简的启动文件.text .global _start _start: ldr r0, 0x53000000 看门狗寄存器地址 mov r1, #0x0 str r1, [r0] 关看门狗 ldr sp, 0x34000000 设置栈指针 bl main 跳转到C的main函数 halt: b halt main返回后就死循环关于栈指针的取值0x34000000 是这类板子常见的内存布局下 SDRAM 顶部的地址具体数值取决于你板子实际内存大小。道理很简单栈是向下生长的你把栈指针设在内存最高地址附近就不会压栈时越界。这块 2440 板子配的通常是 64MB 内存0x34000000 正好是这段区域的边界。如果拿不准就按板子原理图和内存颗粒容量来算。4.2 main.c用指针直接操作寄存器C 语言里操作寄存器本质就是操作一个固定地址的内存单元。我的习惯是先定义宏把寄存器地址和物理意义对应起来#define GPFCON (*(volatile unsigned long *)0x56000050) #define GPFDAT (*(volatile unsigned long *)0x56000054) #define GPFUP (*(volatile unsigned long *)0x56000058) void delay(volatile int count) { while (count--); } int main(void) { GPFCON ~(3 8); GPFCON | (1 8); // GPF4设为输出 GPFDAT | (1 4); // 先输出高电平LED灭 while (1) { GPFDAT ~(1 4); // 低电平LED亮 delay(1000000); GPFDAT | (1 4); // 高电平LED灭 delay(1000000); } }这里有一个非常关键的字volatile。它告诉编译器这个地址的内容可能在程序之外被改变对芯片来说就是硬件自动变化的所以每次访问都必须老老实实去读内存不能优化掉。我见过有人去掉 volatile 后delay 循环被整段优化没了灯要么常亮要么常灭现象极其诡异。GPFCON 那两行的写法值得多说一句先是把目标位的旧值清掉再|把新值写进去。这样做不是为了炫技而是防止破坏同一个寄存器里其他引脚已经配好的状态。点灯可能只用到 GPF4但下一次你可能还想用 GPF5、GPF6如果直接给整个寄存器赋值别的引脚配置就被清掉了。4.3 编译、反汇编验证自己的代码代码写完后执行 make你会得到 start.o、main.o、led.elf、led.bin 四个文件。我强烈建议养成一个习惯反汇编一下看看自己写的代码到底变成了什么机器指令arm-linux-objdump -D led.elf在反汇编输出里你应该能看到 start.S 的 _start 段、关看门狗的三条指令、bl main以及后面 main 里对 0x56000050 和 0x56000054 地址的读写。看到这些你才算真正看见了 C 代码和硬件之间的那层转换。这一步虽然不点亮灯但能帮你排除掉大量我以为写对了的幻觉。用ls -l led.bin看一下文件大小这个数字也有意义。后面烧 NAND 时要特别注意它是否超过 4KB原因后面讲。4.4 玩点花样流水灯和按键控制一颗灯会闪了我很推荐趁热打铁做两个小扩展流水灯和按键点灯。流水灯的核心是让多个 LED 依次点亮在 C 语言里就是用一个掩码变量在 GPFDAT 上循环移位。假设板子上四颗 LED 分别接在 GPF4 到 GPF7那点亮第 i 颗就是把 GPFDAT 的第 (4i) 位清零其他位保持高电平。配合一个合适延时的循环视觉效果就出来了。这个实验能帮你理解寄存器位操作这件事而不是只会复制宏定义。按键控制则需要换一组寄存器因为按键通常接在 GPG 端口。先把对应引脚通过 GPGCON 配置成输入模式然后循环读 GPGDAT 判断按键是否按下。这里有个小坑机械按键按下和松开瞬间会有抖动电平会跳变好几次如果不做消抖程序可能会把一次按下识别成多次。最简单的消抖就是检测到变化后 delay 十几毫秒再确认一次。从点灯到按键你就把 GPIO 的输入和输出两个方向都练了一遍。5. 三种运行方式SDRAM、NOR、NAND5.1 JTAG 下载到 SDRAM 里直接调试调试裸机程序最舒服的方式是用 JTAG 调试器把 led.bin 加载到 SDRAM 的 0x30000000 地址然后把 PC 指针指过去运行。好处是快改完代码编译、下载、运行几秒钟一轮非常适合反复调整延时时长这种场景坏处是 SDRAM 掉电就没了重启板子程序就消失纯粹是调试用的。这里有一个前提容易被忽略JTAG 往 SDRAM 写数据之前SDRAM 必须已经被初始化过。很多开发板的 NOR Flash 里出厂带了一个 bootloader开机它会把 SDRAM 配好这时候你再通过 JTAG 往 0x30000000 灌代码就没有任何问题。如果你把板子的 NOR 清空了、或者直接从 NAND 启动SDRAM 根本没初始化你往那个地址写东西是写不进去的。我刚开始不懂这个用 JTAG 加载一直失败还以为是调试器坏了。5.2 烧 NOR FlashCPU 直接从 0x0 取指NOR Flash 有个特性是支持片内执行也就是 XIP。CPU 复位后直接从地址 0x0 开始取指令而 NOR Flash 恰好挂在 0x0 这段地址上所以代码可以直接在 NOR 里跑不需要先拷贝到内存。这种方式的链接地址必须设为 0x0。你在 Makefile 里把-Ttext 0x30000000改成-Ttext 0x00000000重新编译烧进去就行。如果坚持用 0x30000000 链接的 bin 烧进 NOR除非你整个程序没有任何绝对寻址的全局变量否则必挂。因为点灯程序逻辑简单其实非常适合做成一个上电即跑的 NOR 版本烧一次之后以后每次开机它都会自动跑很有成就感。NOR 的缺点是写速度慢、容量小、价格高但在学习阶段完全够用。5.3 烧 NAND Flash4KB SteppingStone 的坑NAND Flash 和 NOR 不一样它不能直接执行代码CPU 也访问不到它。S3C2440 为了解决这个问题芯片内部集成了一块 4KB 的 SRAM叫 SteppingStone。从 NAND 启动时芯片上电后会自动把 NAND Flash 前 4KB 的内容拷贝到这块 SRAM 里然后 CPU 从 0x0 开始执行。这个机制带来两个实际后果。第一你的裸机 bin 如果超过 4KBNAND 启动时只有前 4KB 被拷贝后面的代码全丢了。要么让整个程序控制在 4KB 以内要么在这 4KB 里写一段搬运代码把位于 SDRAM 里的完整程序拷贝过去再跳转。第二拷贝到 SteppingStone 的这段代码是在 0x0 地址运行的所以这段前导代码必须用 0x0 链接而且如果你想跳到 SDRAM 里的 main注意头上这段只能做绝对跳转不能引用任何 C 语言的全局变量因为那些变量不在 0x0 上。我一直认为能把这个 4KB 的机制彻底讲清楚才算真正理解了 NAND 启动。很多新手在烧 NAND 后遇到完全没反应或者跑一半死掉十有八九就是栽在这两点上。5.4 三种运行方式怎么选运行方式启动地址链接地址特点适合场景JTAG 下载到 SDRAM0x300000000x30000000调试快掉电丢失开发调试阶段烧 NOR Flash0x00x0上电即跑掉电不丢演示、固化简单程序烧 NAND FlashSteppingStone 0x0前4K用0x0有4KB限制需搬运代码实际产品启动流程我的建议是刚开始调点亮逻辑用第一种把功能跑通确定没问题了烧到 NOR 里做一个可以脱离调试器的版本等以后学到 U-Boot 和内核的时候再去认真研究 NAND 启动的搬运流程。一口吃不成胖子运行方式背后涉及的内存控制器、启动流程细节都是后面的事。6. 常见问题与排查思路实录6.1 LED 完全不亮从哪里查起这是最让人头疼的情况但排查顺序其实很固定。我先用万用表量 LED 对应的 GPIO 引脚电平。如果引脚是高电平说明代码要么没跑、要么没配置成输出如果引脚是低电平但灯不亮问题大概率在电路本身比如 LED 焊反、限流电阻虚焊、板子供电不对。代码层面第一件事查 GPFCON 是否配成了输出模式。很多人只写了 GPFDAT 写数据忘了配置方向结果引脚永远保持输入态写什么都没反应。第二件事查寄存器地址有没有写错。0x56000050 和 0x56000005 只差一个位置手一抖就敲反。这也是我为什么坚持让你对着数据手册核对地址而不是抄教程里的宏定义。第三件事查代码有没有进 main。把 JTAG 调试器挂上在 main 入口打断点或者先在 start.S 里点亮另一颗没被占用的 LED 做指示能一步定位是启动文件的问题还是主逻辑的问题。6.2 板子反复重启现象是 LED 亮一下就灭、然后再亮周期规律停不下来。第一嫌疑就是看门狗没关回到 start.S 第一行确认有没有对 0x53000000 写 0。这个坑我到现在还记得当时甚至怀疑是板子坏了。还有一个容易被忽视的原因是链接地址不对导致程序执行到未定义区域触发了异常复位。如果你把链接地址从 0x30000000 换到 0x0或者反过来但没同步改 Makefile运行时会明显异常这种问题同样表现为反复重启。排查思路就是检查 -Ttext 和实际加载地址是否一致。6.3 烧写后完全没反应先确认启动方式和你烧的位置是否匹配。NOR 启动却把程序烧到 NANDCPU 从 NOR 里读出的是空白数据当然没反应反过来也一样。然后检查烧写地址是否覆盖了 bootloader 所在的起始区。很多板子的 NOR 起始区放的是出厂 bootloader你把裸机 bin 直接烧到 0x0 会覆盖它以后再想用串口下载就难了务必先备份原厂 bootloader。如果烧的是 NAND再检查 bin 大小。用ls -l led.bin看一眼超过 4096 字节就要考虑搬运方案或者精简代码把体积压下来。点灯这种程序编译出来通常一两 KB但如果开了编译优化或者加了 printf 之类的东西体积会迅速膨胀。6.4 编译链接期的坑编译时报错要看仔细很多问题不是语法错误而是工具链不匹配。新版的交叉编译器默认可能生成 ARMv7 指令而 S3C2440 是 ARM920T属于 ARMv4T 架构。如果反汇编里出现 movw、movt 这类指令ARM9 是不认识的运行时会触发未定义指令。解决办法是给编译器加上老架构的编译参数比如-marcharmv4t -marm。链接和 Makefile 这边最常见的就是那句missing separator十次有九次是 Tab 被敲成了空格。还有一类问题很隐蔽改了链接地址但不清理旧文件就重新 make导致 start.o 还是旧版本。如果你改了关键参数但行为没变化先执行一次 make clean这是成本最低的排查手段。我个人的经验是把编译、反汇编、烧写、调试的每一步都养成看输出、验证结果的习惯不要为了省事跳过验证。回头想点灯实验最宝贵的地方并不是学会了那几行寄存器操作而是第一次把写代码-编译-下载-调试的整个闭环跑通了。以后再遇到任何一块新板子、任何一颗新芯片我都会先做一遍点灯确认工具链和下载链路是通的再往下走。这个习惯帮我避开了太多后期才爆发的低级问题也推荐你试试。