1. 拿到nRF52840开发板后,先搞清楚你要面对的是什么
nRF52840这颗芯片在低功耗蓝牙圈子里算是老熟人了,Nordic家出的这颗SoC把Cortex-M4F内核、蓝牙5.0协议栈、802.15.4无线能力、USB 2.0控制器全都塞进了一颗6×6mm的QFN封装里。很多人第一次拿到开发板的时候,心态是“不就是点个LED嘛”,结果打开Keil建工程的那一刻就懵了——启动文件选哪个、协议栈怎么加、sdk_config.h里几百个宏到底开哪个,这些问题一个接一个往外冒。
这篇内容就是写给那些手里已经有一块nRF52840开发板(不管是官方DK还是第三方的模组板),想用Keil MDK从零搭一个能跑起来的LED控制工程的人。我不会只给你一串步骤让你照着点,而是把每一步背后的逻辑讲清楚——为什么选这个时钟源、为什么GPIO要配成这个模式、协议栈加进来之后LED为什么还能正常闪。这些“为什么”才是你后面自己改代码、调外设时真正用得上的东西。
先明确一个前提:我下面所有的操作都基于nRF5 SDK 17.1.0版本和Keil MDK 5.36左右的版本。SDK版本不同,目录结构和配置文件会有差异,但核心逻辑是通的。如果你用的是nRF Connect SDK(基于Zephyr那个),那是另一套体系,不在这篇的讨论范围内。
另外说一句关于开发环境的事。Keil MDK在嵌入式圈子里用的人多,但它的License管理确实让人头疼。我的建议是老老实实买正版或者用社区版,别去碰那些来路不明的注册工具——你永远不知道那些东西里面塞了什么。而且现在Keil官方对MDK-Community的授权已经比较宽松了,个人学习完全够用。
2. 工程骨架的搭建:从SDK目录里“偷”一个最干净的起点
2.1 为什么不用Keil的New Project向导
很多人习惯在Keil里点Project → New uVision Project,然后从零开始加文件。对于STM32这种外设寄存器相对规整的芯片,这么做没问题。但nRF52840不一样,它的SDK里包含了大量预编译的协议栈库、板级支持包、外设驱动抽象层,这些文件之间的依赖关系非常复杂。如果你从零建工程,光是搞清楚要加哪些源文件、哪些头文件路径、哪些预编译宏,就够你折腾大半天的。
正确的做法是:直接从SDK的examples目录里找一个最接近你需求的例程,把它复制出来改。对于LED控制来说,examples/peripheral/blinky或者examples/peripheral/gpio就是最好的起点。这两个例程已经把GPIO驱动、板级定义、时钟配置这些基础工作都做好了,你只需要在这个基础上改引脚号和闪烁逻辑就行。
2.2 复制例程时容易漏掉的东西
从SDK目录往外复制例程的时候,有一个坑几乎每个人都会踩:只复制了例程文件夹本身,忘了SDK根目录下的components、modules、integration这些公共目录。nRF5 SDK的例程在Keil里是通过相对路径引用这些公共目录的,如果你把例程复制到一个和SDK根目录没有固定相对关系的位置,编译时就会报一堆“cannot open source input file”的错误。
我的做法是在磁盘上建一个工作目录,比如D:\nrf_work,然后把整个SDK解压到这个目录下,再把例程复制到D:\nrf_work\my_projects\led_demo。这样例程和SDK之间的相对路径关系就保持不变了。具体来说,例程的Keil工程文件里通常会有类似..\..\..\..\components\...这样的路径引用,你只要保证复制后的目录层级和原来一致就行。
还有一个细节:SDK里的例程通常带了多个构建目标(比如nrf52840_xxaa、nrf52840_xxaa_s140等),对应不同的协议栈配置。如果你只是做LED控制,不需要蓝牙功能,选不带协议栈的那个目标就行,编译出来的固件更小,启动也更快。
2.3 Keil工程里的关键配置项
打开复制过来的Keil工程后,有几个地方需要检查一下。在Options for Target → Target标签页里,确认晶振频率设置正确。nRF52840的外部高速晶振通常是32MHz,但如果你用的是内部RC振荡器,这里要选对应的选项。不过对于LED控制这种对时序要求不高的应用,用内部RC也完全没问题。
在C/C++标签页的Preprocessor Symbols里,你会看到一些预定义宏,比如BOARD_CUSTOM、CONFIG_GPIO_AS_PINRESET等。CONFIG_GPIO_AS_PINRESET这个宏特别值得说一下:nRF52840的P0.18引脚默认功能是复位引脚,如果你把它定义成普通GPIO来用,就需要在宏定义里把这个功能关掉。很多人在点LED的时候发现某个引脚死活控制不了,最后查了半天才发现是复位引脚功能没关。
在Debug标签页里,如果你用的是J-Link调试器,选J-LINK/J-TRACE Cortex就行。nRF52840 DK板载的就是J-Link OB,直接USB连上就能用。如果你用的是第三方的DAPLink调试器,那就选CMSIS-DAP Debugger。这里有个经验:J-Link的速度明显比DAPLink快,尤其是在烧录大固件的时候差距很明显,但DAPLink便宜且通用性强,看你的预算和需求。
3. GPIO驱动LED的底层逻辑:不只是“设高设低”那么简单
3.1 nRF52840的GPIO架构有什么特别的
nRF52840有P0和P1两组GPIO,每组32个引脚,总共64个可编程IO。和STM32那种每个引脚都有十几种复用功能的做法不同,nRF52840的GPIO复用相对简单——大部分引脚要么是普通GPIO,要么分配给某个特定外设。这种设计的好处是你不用在复用功能选择上花太多心思,坏处是引脚分配的灵活性稍差一些。
每个GPIO引脚可以独立配置为输入、输出、上拉、下拉、开漏等模式。在输出模式下,你可以通过OUT寄存器设置输出电平,通过OUTSET和OUTCLR寄存器分别实现原子性的置位和清零操作。这里“原子性”的意思是,你不需要先读当前值、再修改、再写回去,而是直接写1到OUTSET就能把对应引脚拉高,写1到OUTCLR就能拉低。这在多任务环境或者中断上下文里特别有用,避免了读-改-写带来的竞态问题。
3.2 用nrf_gpio.h还是直接操作寄存器
nRF5 SDK提供了nrf_gpio.h这个头文件,里面封装了一系列GPIO操作函数,比如nrf_gpio_cfg_output()、nrf_gpio_pin_set()、nrf_gpio_pin_clear()、nrf_gpio_pin_toggle()等。对于大多数应用场景,直接用这些函数就够了,代码可读性好,而且SDK内部做了必要的屏障和同步处理。
但如果你对性能有极致要求,比如要在微秒级别翻转引脚,那就需要直接操作寄存器了。直接写NRF_P0->OUTSET = (1 << pin)比调用nrf_gpio_pin_set()要快几个时钟周期。不过对于LED控制这种毫秒级甚至秒级的应用,这点差异完全可以忽略。
我个人的习惯是:应用层代码用nrf_gpio.h的函数,保持可读性;只有在驱动层或者对时序敏感的场合才直接操作寄存器。这样既保证了代码的可维护性,又能在需要的时候压榨性能。
3.3 LED驱动电路的基本原理和限流电阻计算
开发板上的LED通常是共阳极接法——LED的正极通过限流电阻接到VDD,负极接到GPIO引脚。当GPIO输出低电平时,LED导通发光;输出高电平时,LED熄灭。所以你在代码里看到nrf_gpio_pin_clear()反而是点亮LED,不要搞反了。
限流电阻的取值需要根据LED的正向压降和期望电流来算。假设LED的正向压降是2.0V,VDD是3.3V,期望电流是2mA(对于指示用的LED,2mA已经足够亮了),那么限流电阻R = (3.3 - 2.0) / 0.002 = 650Ω。实际选用680Ω或者1kΩ的标准电阻都可以。电流越小,LED越暗但功耗越低;电流越大,LED越亮但功耗越高。对于电池供电的应用,建议把电流控制在1mA以下。
nRF52840的GPIO在输出模式下的驱动能力是可配置的。在nrf_gpio_cfg_output()的底层实现里,默认用的是标准驱动能力(Standard drive),对应大约2mA的驱动电流。如果你需要驱动更大的电流,比如直接驱动一个高亮LED,可以改用高驱动能力(High drive)模式,对应大约10mA。但要注意,高驱动模式下引脚的功耗和电磁干扰都会增加,不是必要的话不要随便开。
4. 从点亮一颗LED到做出一个可交互的LED控制器
4.1 用定时器实现精确的闪烁节奏
最简单的LED闪烁是用nrf_delay_ms()做延时,然后在循环里翻转引脚。这种做法的缺点是CPU在延时期间完全被占用,什么其他事情都做不了。如果你的应用只需要闪个灯,那没问题;但如果你后面还要加蓝牙通信、传感器采集等功能,这种阻塞式延时就会成为瓶颈。
更好的做法是用定时器来产生周期性的中断,在中断处理函数里翻转LED引脚。nRF52840有5个32位定时器,随便拿一个出来用就行。配置定时器的步骤大概是:设置预分频器让定时器频率在1MHz左右,设置比较值让中断周期为500ms,使能比较中断,然后在中断处理函数里调用nrf_gpio_pin_toggle()。
这里有一个容易忽略的点:定时器的比较值计算。假设预分频器设为4,定时器时钟就是16MHz / 2^4 = 1MHz,也就是每个tick是1微秒。要产生500ms的周期,比较值就是500000。但nRF52840的定时器是32位的,最大计数值是4294967295,所以500000完全在范围内。如果你要产生更长的周期,比如10秒,那就需要更大的预分频器或者用多个比较通道级联。
4.2 按键输入与LED状态的联动
光有LED闪烁还不够,一个完整的LED控制项目通常还需要按键输入。nRF52840 DK上有4个用户按键,对应的引脚在板级定义文件里已经写好了。你可以用轮询的方式读按键状态,也可以用GPIO中断。
轮询方式简单直接,在主循环里每隔几十毫秒读一次按键引脚的电平,如果检测到下降沿就认为按键被按下。但这种方式需要做软件消抖,因为机械按键在按下和释放的瞬间会产生几十微秒到几毫秒的抖动。最简单的消抖方法是在检测到电平变化后延时20ms再读一次,如果电平仍然相同就确认按键动作。
GPIO中断方式更高效,但配置起来稍微复杂一些。你需要配置GPIOTE(GPIO Task and Event)模块,把某个引脚的事件映射到一个中断通道上,然后在中断处理函数里处理按键逻辑。GPIOTE的好处是即使CPU在睡眠模式下,按键事件也能把CPU唤醒,这对于低功耗应用非常重要。
我一般会建议初学者先用轮询方式把功能跑通,等整个项目稳定了再考虑改成中断方式。因为中断方式虽然效率高,但调试起来麻烦——你没法在中断处理函数里打断点然后单步执行,因为那会破坏实时性。
4.3 用PWM实现呼吸灯效果
如果你想让LED有呼吸灯效果,那就需要用到PWM了。nRF52840有4个PWM外设,每个外设可以输出多路PWM信号。配置PWM的基本流程是:定义一个PWM序列(包含占空比和极性),初始化PWM实例,设置序列的循环次数,然后启动PWM。
呼吸灯的关键是让占空比随时间线性变化。你可以在定时器中断里每隔一段时间调整一次PWM的占空比,从0%逐渐增加到100%,再从100%减小到0%。调整的步长和周期决定了呼吸的快慢和流畅度。步长太大呼吸会显得卡顿,步长太小则变化太慢。我的经验是:整个呼吸周期控制在2到3秒,占空比从0到100%分100步完成,每步间隔20到30毫秒,这样出来的效果比较自然。
这里有一个PWM配置的细节:nRF52840的PWM外设支持“EasyDMA”模式,也就是说PWM序列可以放在RAM里,由DMA自动读取,不需要CPU干预。这意味着你可以在CPU睡眠的时候继续输出PWM波形,对于低功耗应用非常友好。但要注意,PWM序列所在的RAM区域在睡眠期间必须保持供电,否则DMA读不到数据。
5. 编译、烧录与调试:那些文档里不会写的实操细节
5.1 Keil编译常见错误与排查思路
第一次编译nRF5 SDK的例程时,最常见的错误是“cannot open source input file”。这通常是因为头文件搜索路径没有配置完整。在Options for Target → C/C++ → Include Paths里,你需要把SDK根目录下的components、modules、integration以及例程自己的config目录都加进去。SDK的例程通常会带一个IncludePaths的文本文件,里面列了所有需要的路径,你可以直接参考。
另一个常见错误是“undefined symbol”或者“multiple definition”。前者通常是某个源文件没有加到工程里,后者通常是同一个源文件被加了两次。nRF5 SDK的例程在Keil里是通过文件组(Group)来组织源文件的,你可以在Project窗口里展开每个组看看有没有重复的文件。
还有一个比较隐蔽的错误是“section .text overflow”。nRF52840的Flash有1MB,RAM有256KB,按理说跑一个LED控制程序绰绰有余。但如果你不小心把协议栈的调试版本(带完整日志输出)编译进去了,Flash占用会急剧增加。解决办法是在sdk_config.h里把NRF_LOG_ENABLED设为0,或者把日志级别调低。
5.2 烧录时的注意事项
用J-Link烧录nRF52840的时候,有一个选项叫“Erase Full Chip”和“Erase Sectors”。如果你只是更新应用固件,选“Erase Sectors”就行,这样不会擦掉协议栈和Bootloader。但如果你之前烧过其他厂商的固件,或者芯片被锁定了,那就需要选“Erase Full Chip”做一次全片擦除。
说到芯片锁定,nRF52840有一个APPROTECT机制,可以防止通过调试接口读取Flash内容。如果你在代码里使能了APPROTECT,那下次再想烧录的时候就需要先做一次全片擦除才能解锁。这个机制本来是为了保护知识产权,但如果你不小心在调试阶段就使能了它,那就只能通过全片擦除来恢复了。所以我的建议是:在开发阶段不要使能APPROTECT,等产品量产前再开。
烧录完成后,如果LED没有按预期闪烁,先检查供电是否正常。nRF52840 DK可以通过USB供电,也可以通过外部电源供电。如果你用的是外部电源,注意电压范围是1.7V到5.5V,但USB供电时是5V经过板载LDO降到3.3V。如果你直接给3.3V引脚供电,要确保电源的纹波足够小,否则芯片可能工作不稳定。
5.3 用Keil的Debug模式查看变量和寄存器
Keil的Debug模式对于调试GPIO问题非常有用。你可以在Watch窗口里添加NRF_P0->OUT、NRF_P0->IN、NRF_P0->DIR这些寄存器,实时观察引脚的状态变化。比如你调用nrf_gpio_pin_set()之后,如果NRF_P0->OUT的对应位没有变成1,那就说明要么引脚配置不对,要么你操作的引脚号和实际硬件不对应。
在Debug模式下查看结构体变量时,Keil默认可能只显示结构体的地址而不是内容。你需要在Watch窗口里展开结构体,或者用*(type*)address的方式强制转换。如果结构体比较大,展开后可能显示不全,这时候可以在Watch窗口里输入结构体成员的完整路径,比如my_struct.member1,这样就能单独查看某个成员的值。
还有一个实用技巧:在Debug模式下,你可以通过Memory窗口直接修改寄存器的值。比如你想临时把某个LED点亮,可以在Memory窗口里找到NRF_P0->OUTSET的地址,直接写入对应的位。这在调试硬件问题时特别方便,可以快速验证是软件问题还是硬件问题。
6. 从LED控制延伸出去:这个项目还能怎么玩
6.1 加入蓝牙5.0实现手机远程控制
LED控制跑通之后,下一步很自然就是加入蓝牙功能,用手机APP远程控制LED的开关和亮度。nRF5 SDK里已经包含了蓝牙协议栈(SoftDevice),你只需要在工程里使能BLE相关的模块,然后调用相应的API就行。
具体来说,你需要做几件事:初始化协议栈、配置GAP参数(设备名称、广播间隔等)、添加一个GATT服务(比如LED Button Service)、在服务里定义LED状态的特征值。手机端可以用Nordic的nRF Connect APP来测试,连接上之后就能看到你定义的服务和特征值,直接读写就能控制LED。
这里有一个经验:蓝牙协议栈的初始化必须在GPIO初始化之前完成,因为协议栈会占用一些外设资源(比如RTC0和部分RAM)。如果你先初始化了GPIO再初始化协议栈,可能会遇到资源冲突的问题。SDK的例程里通常已经处理好了这个顺序,但如果你自己改代码,一定要注意这一点。
6.2 用低功耗模式延长电池寿命
nRF52840的一大卖点就是低功耗。在LED控制项目中,如果你用电池供电,那低功耗设计就很重要了。最简单的低功耗做法是在主循环里调用__WFE()指令让CPU进入睡眠,等有中断事件时再唤醒。对于LED闪烁应用,你可以用定时器中断来唤醒CPU,翻转LED后再继续睡眠。
更激进的做法是使用System OFF模式,这种模式下整个芯片几乎完全断电,只有少数几个唤醒源(比如GPIO中断)还能工作。但System OFF模式下RAM内容会丢失,唤醒后相当于重新上电,所以你需要把关键状态保存在非易失存储器里。
实测下来,在LED每2秒闪烁一次、CPU大部分时间处于睡眠状态的配置下,nRF52840的平均电流可以做到10微安以下。用一颗200mAh的纽扣电池供电,理论上可以运行两年以上。当然实际中还要考虑LED本身的功耗、电源转换效率等因素,但低功耗的潜力确实很大。
6.3 用RTT Viewer替代串口打印调试信息
在调试nRF52840的时候,很多人习惯用串口打印调试信息。但nRF52840 DK的串口是通过板载的J-Link OB虚拟出来的,需要占用一个UART外设。如果你后面要加蓝牙功能,UART可能会和协议栈产生资源冲突。
更好的选择是用SEGGER RTT(Real Time Transfer)。RTT通过J-Link的调试接口传输数据,不需要占用任何外设,速度也比串口快得多。你只需要在工程里加入RTT的源文件,然后调用SEGGER_RTT_printf()就能输出调试信息。在电脑端用J-Link RTT Viewer就能看到输出内容。
RTT的另一个好处是它支持双向通信——你不仅可以从芯片往电脑发数据,还可以从电脑往芯片发数据。这意味着你可以用RTT Viewer给芯片发送命令,实现简单的交互式调试。这在调参数、测试不同配置的时候特别方便,不用每次都重新编译烧录。
7. 一些踩过坑之后才明白的事
第一个坑是关于GPIO引脚编号的。nRF52840的引脚编号是P0.00到P0.31和P1.00到P1.31,但在SDK的板级定义文件里,引脚的编号方式可能和你想的不一样。比如LED_1可能被定义成NRF_GPIO_PIN_MAP(0, 13),也就是P0.13。如果你直接写13,那默认是P0.13;但如果你要操作P1.13,就必须写NRF_GPIO_PIN_MAP(1, 13)。这个宏在编译时会展开成32 + 13 = 45,因为SDK内部把P1的引脚编号从32开始排。如果你不知道这个规则,直接写45也能用,但代码可读性就差很多了。
第二个坑是关于sdk_config.h的。这个文件里有几百个宏定义,控制着SDK各个模块的使能和配置。很多人改了这个文件之后发现编译报错,原因通常是某个宏的依赖关系没搞对。比如你使能了某个模块,但它依赖的另一个模块没有被使能,编译就会报“undefined symbol”。我的建议是:改sdk_config.h之前先备份,每次只改一个宏,编译通过后再改下一个。这样出了问题容易定位。
第三个坑是关于中断优先级的。nRF52840的Cortex-M4F内核支持中断嵌套,但蓝牙协议栈对中断优先级有严格要求。如果你自己配置的中断优先级和协议栈冲突,可能会导致协议栈工作不正常甚至崩溃。SDK里通常会用NVIC_SetPriority()来设置中断优先级,你可以在app_config.h或者sdk_config.h里找到相关的配置项。如果你不用蓝牙功能,那中断优先级可以随便设;但一旦加了蓝牙,就要严格按照SDK的推荐值来配置。
第四个坑是关于电源管理的。nRF52840支持多种电源模式,从Run模式到System OFF模式,功耗依次降低。但模式切换不是没有代价的——从System OFF唤醒需要的时间最长,而且RAM内容会丢失。如果你在低功耗模式下需要保持某些状态,那就不能用System OFF,只能用System ON加CPU睡眠。这个取舍需要根据你的具体应用场景来决定。
最后一个经验是关于代码组织的。一开始你可能把所有代码都写在main.c里,但随着功能增加,main.c会变得越来越臃肿。我的建议是从一开始就把GPIO控制、定时器配置、按键处理这些功能拆成独立的模块,每个模块一个.c和一个.h文件。这样不仅代码清晰,而且后面加蓝牙功能的时候,你只需要在main.c里调用各个模块的初始化函数就行,不用在一堆代码里找哪里该加东西。