跑马灯大概是所有单片机玩家的入门仪式。但同样是跑马灯,用51单片机的寄存器写法、用STM32标准库的写法,和用LPC2124这种ARM7芯片裸机操作,背后的思路完全不一样。这篇我把自己用LPC2124定时器实现跑马灯的完整过程记录下来,从时钟计算、寄存器配置、GPIO驱动到状态机设计,再到实际调试中踩过的坑,全部展开讲清楚,希望能帮到正在啃LPC2124手册、或者刚接触ARM7裸机开发的朋友。
这个项目表面上是点灯,实际上涵盖了ARM7程序开发的几条主线:定时器中断怎么进、匹配寄存器怎么算时间、引脚复用怎么切GPIO、中断服务程序和主循环之间怎么配合。把这些东西串起来,跑马灯只是顺便做出来的效果。所以下面不会只贴一段能跑的代码,而是把每一步的思考过程也写出来,这样你换成别的芯片、别的定时器通道,也能照着一套方法迁移。
1. 跑马灯方案设计:先想清楚再动手
1.1 为什么不用软件延时,而坚持用定时器
很多新手做跑马灯,第一反应是写一个 delay 函数,然后用 while(1) 循环去翻转 LED。这个思路在只做“灯来回闪”的时候确实能跑,但问题在于:delay 期间 CPU 被完全占死了,干不了任何别的事。你哪怕想在点灯间隙扫描一个按键、刷新一个数码管,都要重新设计延时结构,代码很快就变成一团乱麻。
用硬件定时器的核心价值是:定时器自己数数,数到指定值以后产生中断,CPU 只在中断里花极少的时间处理状态,主循环可以空出来做别的事。这是一种“时间基准 + 状态机”的思想。跑马灯的时间基准由定时器提供,灯怎么跑由主循环里的状态机决定,两边解耦,后面想调速、想改成呼吸灯、想加按键交互,都不需要推翻重写。
LPC2124 内部有多个定时器,这里我用 Timer0。它本质是一个 32 位计数器,挂在 VPB 总线上,时钟来自 PCLK。通过预分频器 PR 可以降低计数频率,通过匹配寄存器 MR0~MR3 可以设定比较值,一旦 TC 的值等于 MR0,硬件就会置起中断标志,甚至自动清零 TC 重新计数。这个“自动清零重装”的机制是跑马灯精准定时的基础。
1.2 先确定跑马灯的效果和目标时间基准
开始写代码之前,我习惯先把效果定义清楚。这个例程我设定为 8 个 LED 做往返流水:从第 1 个亮到第 8 个,再从第 8 个回到第 1 个,每个灯点亮 50ms。用间隔 50ms 而不是 500ms,是因为流水效果需要一定速度感,肉眼能明显看到灯在“追着跑”,但又不至于闪到看不清。
50ms 这个数值我不会直接写在定时器匹配值里,而是拆成“时间基准”和“计数倍数”两层:定时器每 10ms 进一次中断,维护一个 tick 计数器,累计 5 个 tick 就执行一次点亮位置的移动。这样做的最大好处是,以后想改成 100ms 间隔,只需要改倍数,不需要动定时器参数。
硬件上,LED 接在 P0.16~P0.23 这 8 个引脚上。这个选择有讲究,后面在 GPIO 部分细说。
2. 定时器的工作原理与寄存器级配置
2.1 从 PCLK 到中断事件,整条链路是怎么走的
很多人配置定时器时只关心“我该往哪个寄存器写什么数”,却不清楚信号是怎么流的。其实搞清楚链路以后,寄存器配置就不用背了。
LPC2124 的 Timer0 时钟链路是这样的:外部晶振给 CPU 提供主时钟 CCLK,CCLK 经过 VPB 分频器产生外设时钟 PCLK。Timer0 首先把 PCLK 送入预分频器,预分频器每计到 PR 寄存器的值就输出一个时钟脉冲给主计数器 TC,TC 每收到一个脉冲就加 1。TC 的值会不断和 MR0~MR3 比较,一旦相等,根据 MCR 寄存器的配置,可以产生中断、复位 TC、或翻转匹配输出引脚。
这就像秒表计时:PCLK 是连续跳动的最小刻度,预分频器相当于把每分钟分成 60 秒,TC 则记录已经过了多少秒,MR0 是预设的闹钟时间。闹钟一响,就是一次匹配事件。
中断产生后,IR 寄存器的对应位会置 1,这个标志不会自动清除,必须在中断服务程序里手动写 1 清除。同时,中断服务程序退出前还要向 VICVectAddr 写 0,通知中断控制器本次中断处理完毕。这两步漏掉任何一个,程序都会表现得非常诡异,后面常见问题里专门说。
2.2 匹配时间到底怎么算,给出可套用的公式
跑马灯的时间精度不要求特别高,但作为基本功,必须会算 PR 和 MR0 的组合。公式其实很简单:
- 计数频率 = PCLK / (PR + 1)
- 单次匹配周期 = (MR0 + 1) / 计数频率
注意这里很多新手会栽跟头:为什么是 PR+1、MR0+1?因为计数器是从 0 开始计数的。PR 写入 2999,那么预分频器会依次计 0、1、2……2999,这已经是 3000 个 PCLK 周期了。同样,MR0 写入 9,TC 会从 0 跑到 9,一共 10 个计数周期。Thật ra就是数数的起点决定了“数了多少个”,而不是“数到了哪个数”。
以我这块板子为例:外部晶振 12MHz,不启用 PLL,所以 CCLK 就是 12MHz。VPB 分频器复位后的默认值是 4 分频,所以 PCLK = 12MHz / 4 = 3MHz。
我需要 10ms 的时间基准。先让计数频率等于 1kHz,也就是每个计数周期 1ms,那么:
- PR = PCLK / 1000 - 1 = 3000 - 1 = 2999
- MR0 = 10 - 1 = 9
这样 TC 从 0 加到 9,正好 10ms。为了后面代码更好维护,我会直接用宏来计算,而不是写死数字。这样换晶振、换 PCLK、换时间基准的时候,只需要改一个宏。
2.3 关键寄存器的配置清单与含义
LPC2124 的 Timer0 有一组寄存器,这里不打算全讲,只列跑马灯必须用的几个,每一个对应什么作用必须清楚。
TCR 是定时器控制寄存器,bit0 写 1 启动计数,写 0 停止;bit1 写 1 复位 TC 和 PC。初始化时一般先写 0x02 复位,再写 0x01 启动。
IR 是中断标志寄存器。MR0 匹配后 bit0 会变 1,读它判断中断来源,写 1 把标志清掉。即使你确定只用了 MR0,我建议还是读出来判断一下,这样代码清晰,以后加其他通道也方便。
MCR 是匹配控制寄存器。bit0 是 MR0I,置 1 允许 MR0 中断;bit1 是 MR0R,置 1 表示 MR0 匹配后自动复位 TC。跑马灯必须这两个同时置 1,否则 TC 会一路加到 0xFFFFFFFF 才归零,中断间隔完全失控。
IMR 是中断屏蔽寄存器,bit0 置 1 放行 MR0 中断信号到 VIC。VIC 是 ARM7 的中断控制器,Timer0 默认是 IRQ 通道 4。VICIntSelect 要把这一位清零,让它走 IRQ 而不是 FIQ;VICIntEnable 的 bit4 置 1 开启通道;VICVectCntl0 和 VICVectAddr0 则把 Timer0 的中断服务函数挂到向量表里。
3. GPIO 与 LED 驱动电路细节
3.1 LPC2124 的引脚复用,这一步漏了灯根本不亮
LPC2124 的 GPIO 引脚不是上电就能直接用的。P0.16~P0.23 默认很可能被复用成其他功能,必须在初始化时通过 PINSEL 寄存器把它们切换成 GPIO 模式。
P0.16~P0.23 属于高 16 位引脚,由 PINSEL1 寄存器管理。每组引脚由 2 个 bit 决定功能,00 表示 GPIO,01、10、11 分别对应不同的第二、第三、第四功能。所以我把 PINSEL1 的低 16 位全部清零,也就是把 P0.16~P0.23 全部切到 GPIO。
然后是方向控制,IO0DIR 的对应位写 1 表示输出。对于 P0.16 到 P0.23,就是高 8 位置 1。方向设置完成之前,引脚还处于输入模式,你写 IO0SET 和 IO0CLR 也不会有什么电平效果。
这里我想特别提醒:LPC2124 的 P0.14、P0.15 这两根引脚比较特殊,P0.14 在复位时如果被拉低,芯片会进入 ISP 下载模式,程序不跑;P0.15 也影响启动行为。所以做实验时我刻意避开了这两个引脚,避免上一电连仿真器都连不上,还以为芯片坏了。
3.2 低电平点亮还是高电平点亮,电路决定代码
点亮 LED 的电路方案有两种常见接法:一种是 IO 输出高电平点亮,LED 正极接 IO,负极经限流电阻接地;另一种是 IO 输出低电平点亮,LED 正极接 VCC,负极经限流电阻接 IO。
LPC2124 的 GPIO 输出高电平时驱动能力并不强,而灌电流能力相对好一些,所以很多开发板上采用低电平点亮。我这个例程也按低电平点亮设计:初始化时把所有 LED 引脚全部输出高电平,也就是全部熄灭;需要点亮某一颗时,把对应引脚拉低。
限流电阻的选择上,假设 LED 压降约 2V,电源 3.3V,电阻两端约 1.3V。选 1kΩ 电阻,电流大约 1.3mA,对于普通高亮 LED 已经足够亮,而且不会给 GPIO 造成压力。如果你发现亮度不够,可以换 470Ω,但不要低于 330Ω,否则电流偏大,长期运行对引脚不友好。
GPIO 的相关寄存器也很简单:IO0SET 是置位寄存器,向某位写 1 让对应引脚输出高电平;IO0CLR 是清零寄存器,写 1 输出低电平。这两个寄存器都是“写 1 生效,写 0 无影响”,所以操作单个引脚不影响其他引脚的状态。
4. 完整代码实现与工程组织
4.1 建议的文件划分与寄存器地址定义
跑马灯代码不多,但我不建议全部塞在 main 文件里。这个项目我用三个文件组织:main.c 负责主循环,timer0.c 负责定时器和中断,led.c 负责 LED 的初始化和状态切换。头文件里统一声明接口函数,定时器和 LED 的细节对外部隐藏。
如果你用的是 Keil MDK 或者 ADS,启动文件会帮你完成堆栈初始化和 main 跳转,这部分不用自己写。寄存器地址我直接在头文件里用宏定义好,比如:
#define T0IR (*(volatile unsigned int *)0xE0004000) #define T0TCR (*(volatile unsigned int *)0xE0004004) #define T0TC (*(volatile unsigned int *)0xE0004008) #define T0PR (*(volatile unsigned int *)0xE000400C) #define T0MCR (*(volatile unsigned int *)0xE0004014) #define T0MR0 (*(volatile unsigned int *)0xE0004018) #define IMR (*(volatile unsigned int *)0xE0004020)注意寄存器地址最好用 unsigned int 指针访问,避免 char 或 short 指针带来的读写宽度问题。VIC 相关的寄存器地址也一并定义好,放在同样的头文件里。
4.2 定时器初始化,把参数算清楚再写代码
定时器初始化的顺序很重要,乱写容易出怪问题。我的习惯是先复位定时器,再配置预分频和匹配值,最后配 VIC 和中断,全部就绪后再启动计数。
时钟参数我用宏定义:
#define FOSC 12000000UL // 外部晶振 12MHz #define CCLK 12000000UL // 未启用 PLL,CCLK = FOSC #define VPBDIV 4UL // VPB 分频系数 #define PCLK (CCLK / VPBDIV) // 3MHz #define TICK_US 10000UL // 时间基准 10ms #define PR_VAL (PCLK / 1000UL) // 计数频率 1kHz #define MR0_VAL (TICK_US / 1000UL) // 10ms 需要 10 个计数初始化函数这样写:
void Timer0_Init(void) { T0TCR = 0x02; // 先复位定时器 T0PR = PR_VAL - 1; // 预分频,让计数频率为 1kHz T0MR0 = MR0_VAL - 1; // 匹配值,10ms T0MCR = (1 << 0) | (1 << 1); // MR0 匹配产生中断,并自动复位 TC T0IR = 0xFF; // 清掉所有中断标志 VICIntSelect &= ~(1 << 4); // Timer0 设为 IRQ VICVectCntl0 = (1 << 5) | 4; // 使能向量槽 0,绑定 Timer0 通道 VICVectAddr0 = (unsigned long)Timer0_IRQHandler; VICIntEnable |= (1 << 4); // 使能 Timer0 中断 T0TCR = 0x01; // 启动定时器 }这里要重点解释 VICVectCntl0 的写法。bit5 写 1 表示该向量槽使能,低 5 位写 4 表示这是 Timer0 的中断通道。如果把通道号写错,中断会跳到一个错误的地方,程序就飞了。VICVectAddr0 存放的是中断服务函数的地址,编译器会自动把函数名转换成地址。
4.3 中断服务函数:标志清除与 tick 累积
中断服务函数要尽量短。我的处理是:先判断是不是 MR0 引起的中断,是的话把标志清掉,然后递增 tick。当 tick 累计到 5 次,也就是 50ms 时,让 LED 状态机走一步。
注意,LPC2124 的 GPIO 操作在主循环和中断里都可能发生,但只要我们只在中断里调用 LED_Step,主循环不直接操作 LED 引脚,就不会产生竞争问题。
volatile unsigned int g_tick = 0; __irq void Timer0_IRQHandler(void) { if (T0IR & (1 << 0)) { T0IR = (1 << 0); // 清 MR0 中断标志 g_tick++; if (g_tick >= 5) { g_tick = 0; LED_Step(); } } VICVectAddr = 0; // 通知 VIC 中断处理结束 }如果你用的编译器是 IAR,中断函数可能是 __irq __arm 修饰;如果是 GCC 环境,可能是attribute((interrupt("IRQ")))。这个写法要跟着工具链走,但逻辑是一致的。
4.4 LED 驱动:查表法实现往返流水
LED 初始化和状态切换放在 led.c 中。我用了一个简单的查表方式:每个 LED 对应一个引脚掩码,状态切换时先全部熄灭,再点亮当前需要亮的那个引脚。
#define LED_START_PIN 16 #define LED_NUM 8 #define LED_ALL_MASK (0xFFUL << LED_START_PIN) static unsigned char led_pos = 0; static unsigned char led_dir = 1; void LED_Init(void) { PINSEL1 &= ~0xFFFF; // P0.16~P0.23 全部设为 GPIO IO0DIR |= LED_ALL_MASK; // 设为输出 IO0SET = LED_ALL_MASK; // 低电平点亮,所以先全部拉高熄灭 } void LED_Step(void) { IO0SET = LED_ALL_MASK; // 熄灭所有 LED if (led_pos == 0) { led_dir = 1; } if (led_pos == LED_NUM - 1) { led_dir = 0; } IO0CLR = (1UL << (LED_START_PIN + led_pos)); // 点亮当前位置 if (led_dir) { led_pos++; } else { led_pos--; } }主函数则非常简单,初始化完以后,主循环就空着了:
int main(void) { LED_Init(); Timer0_Init(); while (1) { // 主循环可以留空,也可以在这做按键扫描等其他任务 } }这个结构看起来简单,但已经具备了一个小型嵌入式裸机程序的骨架:初始化、中断驱动、状态机刷新、主循环空闲。后面你想加按键、加串口打印,只需要挂到主循环里,不用碰中断部分的代码。
5. 调试验证与常见问题速查
5.1 跑起来之后怎么验证时间准不准
程序下载以后,第一件事不是看灯跑得花不花,而是确认时间基准对不对。我一般用示波器或者逻辑分析仪抓某个 LED 引脚的电平,测一个完整流水周期的时间。
比如 8 个灯做往返,每个灯亮 50ms,一个来回是 800ms。如果示波器测出来的周期明显偏长或偏短,说明 PCLK 的计算和实际不一致。最常见的两种原因:一是晶振实际频率不是 12MHz,二是 VPB 分频不是默认的 4 分频。这两种情况下,代码里的 PCLK 和硬件实际值对不上,时间自然不准。
没有示波器的话,也可以用万用表看平均电压,但只能大致判断在闪,测不了精确时间。我更推荐把某个引脚翻转频率放到 1Hz 左右,用手机秒表对着灯光数 10 个周期,误差在一两秒内,基本说明定时器链路是对的。
5.2 典型故障:为什么灯不亮、不流水、乱流水
调试过程中我遇到过不少问题,这里整理成一张速查表,按症状排查会快很多。
症状:LED 全不亮。优先检查引脚复用和方向寄存器。PINSEL1 如果没设为 GPIO,引脚根本不归 GPIO 外设管;IO0DIR 没设输出,写电平无效。这俩是新手最容易漏的。
症状:LED 全亮,但不动。大概率是定时器没有启动,或者 MCR 的 bit1 没置 1。T0TCR 没写 0x01,TC 不计数;MCR 没配自动复位,TC 会跑到溢出才产生一次中断,中间间隔极其漫长,看起来就是没在跑。
症状:中断进了出不来,程序卡死。检查两点:有没有在 ISR 里清中断标志,有没有在 ISR 末尾写 VICVectAddr = 0。ARM7 的 VIC 在中断响应后,如果不清 VICVectAddr,中断返回时会再次触发同一中断,形成死循环。
症状:上电后程序不跑,仿真器也连不上。别再怀疑代码,去量 P0.14 的电平。如果这个引脚被外部电路拉低,芯片会进入 ISP 模式,不执行用户程序。这个坑在 LPC2124 上特别常见。
症状:时间比预期慢一倍。看看 PCLK 是否被配置成了 2 分频或者 1 分频。VPB 分频寄存器复位值通常是 4 分频,但如果 bootloader 或者其他代码改过它,PCLK 就会变。跑不到预期频率时,先在调试器里读一下 VPBDIV 寄存器的实际值。
5.3 进阶扩展:从跑马灯到真正的定时器应用
跑马灯跑顺之后,不建议马上换项目,而是先在原工程上做几个小改动,把定时器的能力吃透。
第一个改动是把流水间隔改成按键调节。每次按下按键,就把 tick 倍率改一下,50ms 变成 100ms、200ms。这能让你理解“中断只负责产生时间基准,业务逻辑在主循环里”的架构价值。
第二个改动是尝试使用两路匹配通道。Timer0 有 MR0~MR3,MR0 继续做流水基准,MR1 可以产生一个 500ms 的慢速中断,用来闪烁另一个状态灯。这能帮你理解多路匹配通道的独立性和共享计数器的特点。
第三个改动是把匹配输出引出来。LPC2124 的部分定时器通道可以把匹配事件直接映射到 MAT 引脚,不需要进中断,硬件自动翻转电平。这种方式用于产生精确方波非常有用,也是你以后做 PWM 输出、输出比较模式的基础。
还有一个我特别推荐的练习:把定时器从“定时中断”换成“输入捕获”,用另一个信号源测量脉冲宽度。跑马灯只是定时器的单向使用,输入捕获才是定时器更强大的另一面。等你把匹配、捕获两条路都打通,再回去看 STM32 的定时器,你会发现大部分概念都能平移过去。
最后再说点实在的
这个跑马灯例程,代码量不到一百行,但它值得反复咀嚼的地方其实很多。我个人在做完这个项目以后,最明显的感觉是:以前写单片机代码,总喜欢把逻辑全部堆在 while(1) 里,靠延时硬撑时序;做完定时器中断驱动以后,才慢慢养成“硬件负责计时,软件负责状态”的思维方式。这种思维方式的转变,比会配几个寄存器重要得多。
如果你在自己实验过程中发现灯跑起来有“一卡一卡”的感觉,先不要急着优化代码,去查一查中断服务函数里是不是做了太多事情。中断里只做标志置位和极其简单的状态切换,其他事情丢给主循环处理,这是裸机编程里性价比最高的优化手段。另外,8 个 LED 的限流电阻如果没有合适阻值,用 1k 附近的都行,不必强求精确,关键是不要从驱动能力弱的高电平方向硬拉电流,那样容易让引脚进入异常状态。
顺着这条路往下走,你可以把跑马灯的 LED 换成 PWM 控制的呼吸灯,或者把 Timer0 的匹配通道扩展成软件定时器,维护一个定时器任务链表。这些都是在同一颗 LPC2124 上能完成的进阶玩法,跑马灯只是个开始。