
简介这是面向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。建工程时芯片型号选CC2540CC2541、CC2530这些邻近型号选项接近别选错。工程建好后第一件事是把链接器配置文件指到lnk51ew_cc2540.xcl这个文件决定了代码段、数据段放哪里选错会直接链接失败。然后是编译优化级别。我习惯在Debug配置下把优化设为None或BalancedRelease配置下再用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. 点亮第一颗LEDGPIO寄存器与时钟才是重点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 0x080x08是四颗灯里最高位亮的状态。注意右移的绕回判断要在移位之后立刻做不然会出现全灭的一拍。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”先查线再查电最后查程序现象点下DownloadIAR弹窗说找不到目标设备连接失败。有时候能连上但下载到一半报错中断。原因最常见的是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负极接IOIO高阻时没有电流回路灯不会闪。软件上把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产生一次中断。想用5msT1CC0改成625想用1ms改成125。工程常识是分频系数越低定时器分辨率越高但T1CC0也要相应调大分频太高则慢速节拍下计数值太小误差变大。我习惯保持128分频通过T1CC0调整时间。6.2 验证你的时钟是不是真16MHz一个LED翻转就能看出来很多人以为芯片默认16MHz就一定是16MHz其实内部RC振荡器会有几个百分点的误差还受温度影响。对流水灯来说差别无所谓对串口来说就成了乱码。验证方法很简单让一个LED每秒翻转一次用逻辑分析仪或示波器测量翻转周期。把T1CC0改成12500即100ms一次中断中断里每10次翻转一次IOvolatile 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时间长了必然乱套。用一个简单的状态机和一张步进表把每种花样做成一个状态流水灯只是其中最简单的一个状态后面加呼吸、加按键长按切换、加蓝牙指令控制都不会推倒重来。希望这个习惯能帮到你也祝你早日从流水灯走向真正能落地的蓝牙小项目。本文还有配套的精品资源点击获取