简介:这是面向CC2540蓝牙芯片初学者的流水灯完整工程示例,通过LED逐个点亮与熄灭的动态效果,演示BLE单片机GPIO输出控制、定时器中断及寄存器配置等核心知识点。压缩包共13个文件,以C源码(.c)、IAR工程文件(.eww/.ewp)、调试与内存映射配置(.ewd/.d51)、编译连接脚本(.dep/.r51)以及HEX固件为主体,另含烧写批处理与项目设置文件,整体只有20KB,结构精炼。该资源已有407人学习使用,适合正在接触8051内核或低功耗蓝牙开发的读者直接打开工程,查看main.c中的流水灯逻辑与中断处理流程。通过研读这份代码,能够快速理解GPIO模式配置、定时器频率设定和中断服务函数的写法,为后续开发更复杂的BLE应用打下基础。
1. CC2540流水灯:一个入门外设Demo,为什么我建议你先别碰协议栈
拿到CC2540开发板,百分之八十的教程会让你直接把蓝牙协议栈跑起来,但很多人在点灯之前就倒在了环境搭建上。CC2540流水灯这个项目,看着只是让几个LED轮流亮灭,实际上是把芯片的IO配置、时钟选择、下载器烧录、延时精度、中断机制全部过了一遍。我见过不少朋友,蓝牙协议栈跑了半年,回头问他P1SEL和P1DIR谁先谁后,还是一脸懵。建议新手和打算用这颗芯片做小项目的工程师,先把流水灯吃透。接下来只讲一件事:怎么把四颗LED按照你想要的速度和方向跑起来,以及这条路上最容易翻车的地方。
2. 搭建CC2540开发环境:选对板子和下载器,少走三天弯路
2.1 开发板与下载器怎么选:CC Debugger是刚需,串口救不了你
CC2540是一颗基于8051内核、带2.4G射频的蓝牙SoC,程序写在片内Flash里。烧录走的是芯片专用的两线Debug接口,不是UART。很多人第一次买板子时图便宜,只买了裸板加一根USB转串口线,结果代码写好了才发现根本烧不进去。常见的做法是买带CC Debugger的整套开发板,或者单独买一个CC Debugger,几十块钱的差价能帮你省下两三天。
Debug接口一共四根线:DC(调试时钟)、DD(调试数据)、RST(复位)、GND,在CC2540上对应P2.1、P2.2、复位脚和地。下载器接到电脑USB口,另一端用排线连到开发板的Debug插座。还要注意,CC2540的工作电压是3.3V,开发板上一定要有稳压电路,别拿5V直接怼,否则芯片当场冒烟。
注意:CC Debugger走的是芯片调试接口,和蓝牙射频链路没关系。哪怕你的板上天线没焊接,流水灯一样能烧录运行,别被“蓝牙必须先用手机连上”的说法带偏。
2.2 用IAR for 8051建工程:这几个工程选项决定你能不能编过
开发CC2540,最常见的方式是用IAR for 8051。建工程时芯片型号选CC2540,CC2541、CC2530这些邻近型号选项接近,别选错。工程建好后,第一件事是把链接器配置文件指到lnk51ew_cc2540.xcl,这个文件决定了代码段、数据段放哪里,选错会直接链接失败。
然后是编译优化级别。我习惯在Debug配置下把优化设为None或Balanced,Release配置下再用Speed级别。原因是8051的优化器有时候会把对IO寄存器的重复赋值合并掉,一个Delay循环也可能被优化成空操作。新手阶段,先保证程序行为可预期,再谈性能。
工具链装好后,在IAR里新建工程,添加一个main.c,先写一个最小框架:
#include <ioCC2540.h> void main(void) { // CC2540的看门狗由WDCTRL寄存器控制 // 显式关闭看门狗,避免程序运行中被意外复位 WDCTRL = 0x00; while (1) { // 空循环,后续在这里加LED控制逻辑 } }这段代码里的WDCTRL是CC2540的看门狗控制寄存器,写成0x00等于关掉看门狗。之所以要特意写这一行,是因为这颗芯片在不同复位配置下看门狗状态并不完全一致,保险起见显式关闭一次,成本最低,能避免跑着跑着片子自动复位。新手经常在查了半天电路之后才发现是看门狗在捣乱。
这里提一句头文件。ioCC2540.h是芯片原厂提供的8051头文件,里面定义了所有特殊功能寄存器和端口的位操作宏,比如P1_0、P1DIR、P1SEL这些。编译能过的前提是这个头文件在IAR的包含路径里能找到,工程模板一般都会带,你新建的裸工程需要手动加一下路径。
2.3 烧录并跑通第一个程序:三步操作和“连不上”的常规排查
编译通过后,把CC Debugger插上电脑,开发板通电,IAR里直接点Download。整个流程就三步:接线、供电、下载。但第一次大概率会碰到下载器连不上目标芯片,也就是IAR提示类似“Cannot find target device”的错误。碰到这种情况别慌,按下面顺序查:
第一步,确认Debug排线没接反。DC、DD、RST三根线如果错位,芯片没法跟下载器握手。第二步,确认开发板真的通电了,并且电源指示灯亮。CC Debugger自身不带大电流供电,裸板靠Debug口那点电是跑不起来的。第三步,确认电脑上已经正确安装下载器的USB驱动,设备管理器里能看到对应设备。
还有一个细节:下载时IAR会尝试连接芯片,如果你的程序里刚好把P2.1或P2.2重新配置成了普通IO,调试口就会被占用,下载器就抓不到芯片了。这种“锁死”现象新手经常遇到,解决办法是用Flash烧录工具里的强制擦除功能(Erase All),把整片Flash清空再重新烧。具体怎么操作,我在避坑章节里再展开。
3. 点亮第一颗LED:GPIO寄存器与时钟才是重点
3.1 引脚功能配置:P1SEL、P1DIR和P1INP的配合
CC2540的IO端口比普通8051复杂一点,因为每个引脚既要当普通IO用,又要能切换成外设功能(比如UART、SPI、定时器输出)。决定引脚是IO还是外设功能的是P1SEL寄存器,某一位为0表示对应引脚作为GPIO使用,为1则表示交给外设复用。决定GPIO方向的是P1DIR,写1是输出,写0是输入。
以P1.0到P1.3接四颗LED为例,初始化代码是这样:
#include <ioCC2540.h> // 用宏把LED引脚定义清楚,后续硬件改动时只改这里 #define LED_PORT P1 #define LED_MASK 0x0F // 低四位对应四个LED void LedInit(void) { // 先把P1.0~P1.3设为GPIO模式 // P1SEL低四位写0,其他位保持原值 P1SEL &= (unsigned char)~LED_MASK; // 再把P1.0~P1.3设为输出方向 // P1DIR低四位写1 P1DIR |= LED_MASK; // 初始全部熄灭,这个顺序很重要 LED_PORT &= (unsigned char)~LED_MASK; }先配置P1SEL、再配置P1DIR,最后把所有LED设为熄灭,这个顺序我建议不要反过来。原因是CC2540上电后端口方向默认为输入,会有一个极短的上电窗口,引脚电平由外部电路决定,如果不先把输出数据寄存器写为0,等方向切到输出时LED可能闪一下。先把灯灭掉再切方向,可以从根上避免这个瞬间闪亮。
另外一个寄存器P1INP也要了解。它控制IO引脚的内部上拉/下拉是否启用,但只对输入模式的引脚有效,输出模式不受影响。在流水灯里,除非你要做按键输入读取,否则P1INP不需要动,默认值即可。
3.2 LED硬件接法:高电平点亮还是低电平点亮,代码差一个“!”
搞嵌入式的人常说硬件决定软件,LED电路就是这种典型。如果LED的正极通过限流电阻接到3.3V,负极接单片机引脚,那IO输出低电平时LED才亮,这就是低电平点亮。反过来,引脚输出高电平点亮则是高电平点亮。
我建议统一用低电平点亮:正极接电源、负极接IO。好处有两个,一是芯片IO的灌电流驱动能力一般比拉电流能力强,LED更亮;二是MCU复位期间IO默认是输入高阻,LED不会在这个不确定阶段被点亮。如果你手上的板子恰恰是低电平点亮,上面代码里P1 &= ~LED_MASK来熄灭就是对的;如果是高电平点亮,熄灭要写成P1 |= LED_MASK。
3.3 时钟源与主频:默认16MHz RC振荡器,延时函数要按实际频率算
写流水灯就要写延时。CC2540有多个时钟源,上电默认用内部16MHz RC振荡器,这个频率足够精确跑流水灯,但对UART波特率来说会有误差,需要校准。这里我们先讲怎么按16MHz来计算延时。
void DelayMs(unsigned int ms) { unsigned int i, j; for (i = 0; i < ms; i++) { // 内层循环次数没有严格数学公式可套 // 16MHz、编译器优化为None时,大约1600次循环约等于1ms // 改了编译优化级别后,这个数字要重新标定 for (j = 0; j < 1600; j++) { // 空循环,编译器不要优化掉 } } }这个延时函数是纯软件空循环,说它是玄学一点不过分。实际耗时取决于编译器优化级别、代码是从Flash运行还是RAM运行,甚至温度对RC振荡器的影响。所以我的建议是:流水灯这种演示项目用空循环延时无所谓,但一旦涉及串口通信或时间敏感逻辑,必须换成硬件定时器。后面第6章会专门讲定时器方案。
如果你想切到外部32MHz晶振,一般做法是先把CLKCONCMD切到32MHz源,再轮询CLKCONSTA确认稳定,最后把系统主时钟切过去。入门阶段不建议动它,默认16MHz足够。
3.4 点亮一灯的完整代码与“灯没亮”的排查逻辑
把上面几段组合起来,第一颗LED常亮的完整程序是这个样子:
#include <ioCC2540.h> void main(void) { // 显式关闭看门狗 WDCTRL = 0x00; // IO初始化:P1.0~P1.3作为输出,默认灭 P1SEL &= (unsigned char)~0x0F; P1DIR |= 0x0F; P1 &= (unsigned char)~0x0F; // 点亮P1.0对应的LED // 如果板子是高电平点亮,这一行改成 P1 |= 0x01 P1 &= (unsigned char)~0x01; while (1) { // 保持常亮 } }代码逻辑很简单。如果烧进去灯没亮,我的排查顺序是:先看硬件,用万用表量LED两端电压,正常情况点亮时压差约1.8V到2.0V;再看IO电平,把万用表打到电压档,测P1.0对地电压,低电平点亮时应该接近0V,如果接近3.3V说明代码逻辑写反了;最后才回头查原理图,确认LED到底是接在P1.0还是别的引脚。先怀疑代码、不怀疑图纸,是新手最容易犯的错误。
4. 让流水灯跑起来:移位逻辑、按键调速与中断接入
4.1 循环移位实现“水流动画”:左移、右移与绕回
流水灯的本质,是让一个亮着的LED按固定节拍向左或向右移动。用一个unsigned char变量保存当前亮灯位置,每次移动就是左移一位或右移一位,移出边界后绕回另一端。这是最朴素也最好理解的实现:
#include <ioCC2540.h> void DelayMs(unsigned int ms); void main(void) { unsigned char ledState = 0x01; // 初始状态:最低位亮(00000001) // 显式关看门狗 WDCTRL = 0x00; // IO初始化:P1.0~P1.3作为输出,默认灭 P1SEL &= (unsigned char)~0x0F; P1DIR |= 0x0F; P1 &= (unsigned char)~0x0F; while (1) { // 只改P1低四位,高四位保持原值 P1 = (P1 & 0xF0) | ledState; // 停留时间决定“流水”节奏 DelayMs(300); // 左移一位,模拟水流方向 ledState <<= 1; // 溢出检查:左移4次后ledState变成0x10 // 说明已经移出边界,绕回0x01 if (ledState == 0x10) { ledState = 0x01; } } }这里有两个细节值得说。第一,P1的高四位可能接了其他东西(比如按键或调试引脚),所以用P1 = (P1 & 0xF0) | ledState只改低四位,避免覆盖高四位。第二,溢出判断用了固定的0x10,如果LED数量不是4颗,要同步改成1 << 灯数,否则流水会乱。
如果想让流水方向反过来,把左移改成右移,溢出判断改成if (ledState == 0x00) ledState = 0x08,0x08是四颗灯里最高位亮的状态。注意右移的绕回判断要在移位之后立刻做,不然会出现全灭的一拍。
4.2 按键换向与调速:最简消抖方案是“边沿检测+延时确认”
光会闪还不够,很多教程最后会要求加一个按键,按一下方向反转。这个需求正好把GPIO输入、消抖、状态更新三个知识点串起来。按键接在P0.0,另一端接地,按下为低电平。读取按键时要把P0.0设为输入并启用内部上拉:
#include <ioCC2540.h> #define KEY_PIN P0_0 void KeyInit(void) { P0SEL &= (unsigned char)~0x01; // P0.0作为GPIO P0DIR &= (unsigned char)~0x01; // P0.0方向为输入 P0INP &= (unsigned char)~0x01; // 使能内部上拉 } unsigned char KeyScan(void) { static unsigned char lastKey = 1; // 上一次电平,初始为高(未按下) unsigned char curKey; curKey = KEY_PIN; if (lastKey == 1 && curKey == 0) { // 检测到下降沿,等10ms再确认一次,避开机械抖动 DelayMs(10); if (KEY_PIN == 0) { lastKey = 0; return 1; // 表示“确实按下了一次” } } // 松手时恢复lastKey,下次按下才能再次检测到下降沿 if (curKey == 1) { lastKey = 1; } return 0; }这个消抖思路比“按下延时20ms然后直接判断”要好。它是靠边沿触发,只有电平从高变低时才做延时确认,主循环每次调用都能立即返回,不会因为等待消抖而卡住流水灯。如果做成按下时延时一下,流水灯会在每次按键时停顿,体验很差。
在main循环里,把KeyScan的返回值和方向变量结合起来。方向变量用全局量:
volatile unsigned char g_dir = 0; // 0:向左流,1:向右流 volatile unsigned int g_speed = 300; // 节拍时间,单位毫秒每次循环先扫描按键,返回1就翻转g_dir,再用g_speed做延时。变量加volatile,是因为它们在主循环之外可能被修改,加了之后读到的永远是真实值,不会因为编译器优化而拿到旧值。
4.3 定时器1的中断接入:把“占CPU的延时”换成“硬件节拍”
用空循环延时会让CPU在原地空转。按键扫描虽然做在主循环里,但每次DelayMs(300)期间CPU什么都干不了,按键响应最快也要300ms之后。中断方式原理不复杂:让定时器每10ms产生一次中断,在中断里对一个计数器加一,主循环检查计数器是否到30,到了就换一帧LED状态。
#include <ioCC2540.h> volatile unsigned int g_10msCnt = 0; // 每10ms加1的中断计数 void Timer1Init(void) { // T1CTL:定时器1控制寄存器 // 0x0A = 0000 1010 // bit3-bit2分频:10 -> 128分频 // bit1-bit0模式:10 -> 自由运行模式,计数到0xFFFF溢出 T1CTL = 0x0A; // 自由运行模式下T1CC0是比较值 // 16MHz / 128 = 125kHz,计数周期8us // 10ms需要计数1250次 T1CC0 = 1250; // 通道0比较模式,允许产生比较事件 T1CCTL0 = 0x04; // 使能定时器1中断和总中断 T1IE = 1; EA = 1; } #pragma vector = T1_VECTOR __interrupt void Timer1ISR(void) { // 清通道0的中断标志位 T1STAT &= (unsigned char)~0x01; // 计数累加,主循环根据这个计数决定是否切帧 g_10msCnt++; }提示:定时器中断不触发时,先检查T1IE和EA这两个使能位是不是都置了1。很多人只开了T1IE,忘了总中断EA,结果中断一直不进来,流水灯纹丝不动。
主循环里不再用DelayMs,改成每300ms切换一次:
void main(void) { unsigned char ledState = 0x01; unsigned int lastTick = 0; WDCTRL = 0x00; P1SEL &= (unsigned char)~0x0F; P1DIR |= 0x0F; P1 &= (unsigned char)~0x0F; Timer1Init(); while (1) { // g_10msCnt比lastTick大30,表示过了至少300ms if ((unsigned int)(g_10msCnt - lastTick) >= 30) { lastTick = g_10msCnt; // 左移并绕回 ledState <<= 1; if (ledState == 0x10) ledState = 0x01; // 更新LED输出 P1 = (P1 & 0xF0) | ledState; } // 主循环空余时间扫描按键,不受300ms限制 // 这里可以调用KeyScan(),并在返回1时翻转g_dir } }注意我用了(unsigned int)(g_10msCnt - lastTick)这种方式判断时间差,而不是直接比较g_10msCnt的绝对值和30的倍数。原因是计数会一直涨,如果哪天增加到65535之后回绕到0,绝对值判断就失效了,差值判断则永远正确。这是嵌入式里很常用的小技巧。
5. CC2540流水灯避坑指南:5个必踩的坑与排查方法
5.1 烧录报“Cannot find target”:先查线,再查电,最后查程序
现象:点下Download,IAR弹窗说找不到目标设备,连接失败。有时候能连上,但下载到一半报错中断。
原因:最常见的是Debug排线接错。CC2540的P2.1对应DC(调试时钟),P2.2对应DD(调试数据),排线如果正反错位,直接连接失败。第二个常见原因是目标板供电不足,CC Debugger的电源输出能力有限,板载稳压芯片耗电大时芯片可能欠压,无法正常握手。第三个原因才是程序问题,上一版程序恰好把P2.1/P2.2配置成GPIO并输出,占用了调试引脚。
解决:先量开发板电源3.3V是否稳定;再核对排线顺序;最后用Flash烧录工具里的强制擦除功能,在芯片上电瞬间擦除Flash,把调试引脚恢复出厂可用状态。
5.2 流水灯方向反了或者灯不亮:先翻原理图,别在代码里瞎猜
现象:代码明明是照着“高电平点亮”写的,烧进去之后要么四个灯全灭,要么亮的灯完全反过来。
原因:板子实际用的是低电平点亮。我遇到过一位开发者,代码里写P1 |= 0x01想点亮P1.0,灯死活不亮,拿万用表一量,P1.0已经是高电平了,LED两端反向偏置,当然不亮。查原理图才知道,LED正极接3.3V、负极接IO,需要输出低电平才亮。
解决:拿到一块新板子,第一件事是看原理图里LED的接法,确定是正极接IO还是负极接IO。然后按这个原则写代码:低电平点亮就P1 &= ~mask点亮、P1 |= mask熄灭;高电平点亮则完全相反。这个习惯能省掉无数次瞎猜。
5.3 上电瞬间所有灯闪一下:GPIO默认输入状态与不确定电平
现象:程序正常运行,LED亮灭逻辑正确,但每次上电瞬间四个灯会一起快速闪一下。
原因:CC2540复位后所有IO默认是输入方向,内部上拉/下拉默认禁用,引脚电平处于高阻不定状态。LED如果按“高电平点亮”接法,这个不定状态可能让灯微亮或全亮一下,等程序初始化IO后才熄灭。
解决:硬件上优先采用低电平点亮电路,复位后LED负极接IO,IO高阻时没有电流回路,灯不会闪。软件上把PortInit放到main函数第一行,先写数据寄存器再改方向,能缩短闪灯窗口。两个手段配合基本可以消除。
5.4 按键一按流水灯乱套甚至复位:消抖没做好,电源也没处理好
现象:按键按下去,灯节奏乱跳,一次按键常常连续触发好几轮换向,严重时整个板子复位重新跑。
原因:机械按键的抖动时间通常在5ms到15ms,按下和松开的瞬间会产生一连串高低电平脉冲。如果代码只用一次电平判断,一次物理按键会被识别成多次。另外,按键按下瞬间跳变沿很陡,板载电源稳压余量不足时,内核电压跌落会引发复位。
解决:软件上,在检测到下降沿后延时10ms再确认一次,松开后也要等抖动结束再更新上一次电平。硬件上,在按键两端并联一个100nF陶瓷电容,吸收机械抖动;在芯片VDD附近放一个100nF去耦电容,避免瞬间大电流拉低电源。这两个动作成本极低,效果立竿见影。
5.5 下载完第一次之后再也连不上:调试引脚被占用或芯片进了睡眠
现象:第一版程序烧录成功,改完代码想烧第二版,下载器连不上。
原因:程序把P2.1或P2.2重新配置成GPIO,调试接口被切断;或者程序进入了低功耗模式,芯片休眠后调试时钟无法同步。
解决:如果还能连上,先在main函数开头设断点,让程序停在初始化之前,再下载新程序。如果已经连不上,用Flash烧录工具的强制擦除功能,上电瞬间擦除全片Flash,把调试引脚恢复。记住一条规则:P2.1和P2.2这两个调试引脚,不要在正式代码里长期占用,调试期例外。
6. 用定时器把流水灯改成精确节拍,顺便验证你的时钟准不准
6.1 自由运行模式下的T1CC0:如何算出10ms的比较值
定时器1在自由运行模式下,计数器从0x0000一直数到0xFFFF,然后溢出回0。T1CC0是比较值,计数器到达它时通道0会产生比较事件。我们用的中断频率,就是时钟频率除以分频系数再除以T1CC0。
定时器时钟 = 16MHz / 128 = 125kHz 每个计数周期 = 1 / 125kHz = 8us 10ms需要的计数个数 = 10ms / 8us = 1250所以T1CC0 = 1250,配合128分频,正好10ms产生一次中断。想用5ms,T1CC0改成625;想用1ms,改成125。工程常识是:分频系数越低,定时器分辨率越高,但T1CC0也要相应调大;分频太高则慢速节拍下计数值太小,误差变大。我习惯保持128分频,通过T1CC0调整时间。
6.2 验证你的时钟是不是真16MHz:一个LED翻转就能看出来
很多人以为芯片默认16MHz就一定是16MHz,其实内部RC振荡器会有几个百分点的误差,还受温度影响。对流水灯来说差别无所谓,对串口来说就成了乱码。验证方法很简单:让一个LED每秒翻转一次,用逻辑分析仪或示波器测量翻转周期。
把T1CC0改成12500,即100ms一次中断;中断里每10次翻转一次IO:
volatile unsigned char g_cnt = 0; // 验证前先把P1.4设为普通输出 P1SEL &= 0xEF; P1DIR |= 0x10; #pragma vector = T1_VECTOR __interrupt void Timer1ISR(void) { T1STAT &= (unsigned char)~0x01; g_cnt++; if (g_cnt >= 10) // 100ms * 10 = 1s { g_cnt = 0; P1_4 = !P1_4; // 输出方波,周期应为2秒 } }如果实测P1.4方波的高电平时长不是1.000秒,而是0.96秒或1.05秒,说明实际时钟不是16MHz,可能是RC振荡器偏差,也可能你已经切到了32MHz外部晶振却忘了改分频。这个验证方法我在每个项目里都会用,花两分钟就能排除最基础的时钟隐患。
6.3 流水灯的下一步:把状态机扩展成呼吸灯或跑马灯花样
定时器方案跑通后,流水灯这个项目就算毕业了。我自己的习惯是再做几个小扩展:把节拍变量从固定值改成按键可调的速度档位,比如300ms、200ms、100ms三档循环;或者用T1CC0连续变化模拟PWM呼吸灯效果,原理是让比较值在每次中断里递增,IO在匹配时翻转,占空比就跟着变了。
最后说一条血泪经验:凡是涉及多个LED花样效果的嵌入式Demo,不要把所有逻辑都塞进一个while循环里写if-else,时间长了必然乱套。用一个简单的状态机和一张步进表,把每种花样做成一个状态,流水灯只是其中最简单的一个状态,后面加呼吸、加按键长按切换、加蓝牙指令控制,都不会推倒重来。希望这个习惯能帮到你,也祝你早日从流水灯走向真正能落地的蓝牙小项目。
本文还有配套的精品资源,点击获取