NRF52840串口通信实战:从UARTE驱动到DMA高效应用
1. 项目概述从零到一打通NRF52840的“对话”通道搞嵌入式开发尤其是玩蓝牙SoC串口UART绝对是你第一个要打交道的“老朋友”。它不像I2C、SPI那样需要严格的时序协议也不像USB那样复杂它就是最简单、最直接的字节流传输。对于NRF52840这颗功能强大的芯片来说串口不仅仅是下载程序、打印日志的“后勤通道”更是连接传感器模块、与其他主控通信、甚至进行产品前期调试的核心手段。我刚开始接触NRF52840时也以为配置个串口是分分钟的事结果在时钟源选择、DMA配置、流控引脚这些细节上栽了跟头。这篇内容我就把自己从踩坑到熟练使用NRF52840串口的完整历程拆解一遍目标是让你看完之后不仅能实现基础的收发更能理解Nordic SDK中UART驱动的设计哲学搞定异步操作、DMA传输这些进阶玩法最终写出稳定、高效的串口通信代码。无论你是刚拿到DK开发板的新手还是正在为产品调试串口通信的老鸟这里面的经验都能直接拿去用。2. 核心需求与方案选型为什么是UARTE拿到“实现串口通信”这个任务第一步不是急着写代码而是先想清楚我要用串口来干什么不同的应用场景直接决定了底层驱动的配置策略。2.1 典型应用场景分析对于NRF52840串口的使用无外乎下面几种情况调试输出Logging这是最普遍的需求。通过printf函数将程序运行状态、变量值、错误信息打印到PC端的串口助手如SecureCRT、Putty或各种串口调试助手。这种场景对实时性要求不高但要求稳定、不丢数据并且最好不占用CPU太多时间。与PC上位机通信实现自定义的通信协议让NRF52840与PC上的软件交换数据。例如传输传感器数据包或接收PC下发的控制指令。这里就需要考虑数据帧的解析、校验以及通信的可靠性。与外部模块通信很多GPS模块、LORA模块、某些传感器都采用串口作为通信接口。这时NRF52840作为主机需要按照模块的指令集通常是ASCII码或二进制协议进行问答式通信。Bootloader通信如果你要设计OTA空中升级功能一个常见的做法是Bootloader程序通过串口接收新的固件数据包并写入Flash。这对串口的数据吞吐率和可靠性有较高要求。2.2 Nordic SDK中的UART驱动UART vs UARTE这是新手最容易困惑的地方。在nRF5 SDK中你会发现有两套APInrf_drv_uart.c和nrf_drv_uarte.c。它们有什么区别UARTLegacy UART这是传统的、基于轮询Polling或简单中断的驱动。当你调用nrf_drv_uart_rx或tx函数时CPU需要忙等待阻塞直到操作完成或者配置一个非常基础的中断。它的实现相对简单但效率低下尤其在接收不定长数据时非常麻烦。UARTEUART with EasyDMA这是强烈推荐使用的现代驱动。E代表EasyDMA这是Nordic芯片的一个特色外设可以理解为一个小型的、专用于数据搬运的协处理器。核心优势UARTE可以与EasyDMA配合实现自动化的数据搬运。你只需要告诉DMA源地址比如接收缓冲区和目标地址比如UART的数据寄存器设置好传输数量DMA就会在后台完成数据搬移完全不需要CPU干预。传输完成后通过一个中断通知CPU即可。带来的好处极低的CPU占用率CPU被解放出来处理其他任务系统整体效率高。方便接收不定长数据可以结合超时中断RXTO事件实现非常优雅的不定长数据包接收。更高的可靠性减少了因CPU忙于其他任务而错过串口数据的风险。所以除非有极其特殊的理由比如资源极度紧张否则一律选择UARTE驱动。我们的后续所有实现都将基于UARTE。2.3 硬件连接与引脚规划NRF52840的串口引脚是高度复用的几乎任何GPIO都可以配置为UART的TX、RX、RTS、CTS。这带来了灵活性但也需要在项目初期就规划好。必需引脚TX (Transmit)芯片数据输出引脚连接至USB转串口模块如板载的FTDI或CH340的RX。RX (Receive)芯片数据输入引脚连接至USB转串口模块的TX。切记TX接RXRX接TX交叉连接这是最常接错的地方。可选引脚硬件流控RTS (Request To Send)请求发送。NRF52840通过此引脚告诉对方“我准备好接收了你可以发数据给我”。通常接对方CTS。CTS (Clear To Send)清除发送。NRF52840通过此引脚检测对方是否准备好接收“你准备好了吗好了我就发”。通常接对方RTS。何时需要流控当通信速率很高比如921600bps以上或者接收端缓冲区可能来不及处理时启用硬件流控可以防止数据丢失。对于大多数调试和中等速率通信可以不接。我的配置建议在原理图或PCB设计时就固定好一对用于调试的串口引脚例如使用P0.05 (TX) 和 P0.06 (RX)。很多开发板如Nordic的DK已经这样做了。如果产品中还需要连接其他串口设备再规划另一组GPIO并在代码中通过宏定义灵活切换例如#define DEBUG_UART_TX_PIN 5 #define DEBUG_UART_RX_PIN 6 #define GPS_UART_TX_PIN 8 #define GPS_UART_RX_PIN 9注意NRF52840的工作电压是1.7V至3.6V其GPIO电平是VDD电平。如果你的USB转串口模块是5V电平一些老的PL2303、CH340模块绝对不能直接连接必须使用电平转换电路如TXS0108E否则可能损坏芯片3. 开发环境搭建与基础驱动配置工欲善其事必先利其器。在写代码之前确保你的环境是顺畅的。3.1 软件环境准备SDK与Toolchain确保你安装了较新版本的nRF5 SDK如17.1.0和对应的GNU Toolchain。Segger Embedded Studio (SES) 或 Keil MDK 任选其一我个人更推荐SES因为对Nordic芯片的支持更原生。串口驱动检查你的开发板或USB转串口模块用的是哪款芯片常见的有FT232、CP2102、CH340、CH341。去芯片厂商官网下载最新的驱动程序并安装。在Windows设备管理器中正确安装后会出现新的“端口COM和LPT”里面会有类似USB Serial Port (COM3)的条目记住这个COM号。串口调试助手选择一款功能齐全的。SecureCRT、Putty是经典终端工具SSCOM、XCOM是国内开发者常用的调试助手界面友好支持中文和多种数据格式显示。我习惯用SSCOM因为它发送十六进制、循环发送、数据保存等功能都很方便。3.2 在工程中引入UARTE驱动在SDK中UARTE驱动文件位于\components\drivers_nrf\uarte\。你需要在工程中包含相关头文件并链接对应的C文件。在SES中通常通过project.mk或直接添加文件到工程。关键是要包含以下路径\components\drivers_nrf\\components\libraries\依赖一些基础库3.3 UARTE实例初始化配置详解这是最核心的一步。我们创建一个uart_init()函数。#include nrf_drv_uarte.h #include app_uart.h // 如果你使用app_uart模块它底层调用了UARTE这个更简单 #include nrf_log.h #include nrf_log_ctrl.h #include nrf_log_default_backends.h // 定义UARTE实例和引脚 #define UARTE_INSTANCE_ID 0 // 使用UARTE0外设 #define UART_TX_PIN 5 #define UART_RX_PIN 6 #define UART_RTS_PIN NRF_UARTE_PSEL_DISCONNECTED // 不使用RTS #define UART_CTS_PIN NRF_UARTE_PSEL_DISCONNECTED // 不使用CTS // 定义UARTE实例句柄 static nrf_drv_uarte_t m_uarte_instance NRF_DRV_UARTE_INSTANCE(UARTE_INSTANCE_ID); // 接收缓冲区 static uint8_t m_rx_buffer[256]; static uint8_t m_tx_buffer[256]; void uart_init(void) { ret_code_t err_code; // 1. 配置UARTE参数结构体 nrf_drv_uarte_config_t uarte_config NRF_DRV_UARTE_DEFAULT_CONFIG; uarte_config.pseltxd UART_TX_PIN; uarte_config.pselrxd UART_RX_PIN; uarte_config.pselrts UART_RTS_PIN; uarte_config.pselcts UART_CTS_PIN; uarte_config.hwfc NRF_UARTE_HWFC_DISABLED; // 禁用硬件流控 uarte_config.parity NRF_UARTE_PARITY_EXCLUDED; // 无校验位 uarte_config.baudrate NRF_UARTE_BAUDRATE_115200; // 波特率 uarte_config.interrupt_priority APP_IRQ_PRIORITY_LOW; // 中断优先级 // 2. 初始化UARTE实例 err_code nrf_drv_uarte_init(m_uarte_instance, uarte_config, uart_event_handler); APP_ERROR_CHECK(err_code); // 3. 启动接收这是关键初始化后立即启动一次接收让DMA开始监听RX线。 nrf_drv_uarte_rx(m_uarte_instance, m_rx_buffer, sizeof(m_rx_buffer)); NRF_LOG_INFO(UARTE initialized successfully.); }关键点解析NRF_DRV_UARTE_DEFAULT_CONFIG这是一个宏它提供了一个默认的配置。默认波特率是NRF_UARTE_BAUDRATE_115200无硬件流控无奇偶校验。我们在此基础上覆盖引脚等参数。中断优先级APP_IRQ_PRIORITY_LOW是一个在sdk_config.h中定义的宏通常为3。对于串口这种不紧急的外设低优先级是合适的。如果你有实时性要求极高的任务需要注意中断嵌套问题。nrf_drv_uarte_rx这个调用极其重要。它并不是“接收函数”而是启动接收DMA传输的命令。它告诉UARTE和EasyDMA“请把接下来收到的数据存放到m_rx_buffer里最多存sizeof(m_rx_buffer)个字节”。调用一次后DMA就开始工作了。当缓冲区满或者发生错误时会产生事件我们在事件处理函数中处理。3.4 事件处理函数通信的中枢神经UARTE的所有异步操作结果都通过事件回调函数通知应用层。这是理解UARTE工作模式的关键。// UARTE事件处理函数 void uart_event_handler(nrf_drv_uarte_event_t const * p_event, void * p_context) { switch (p_event-type) { case NRF_DRV_UARTE_EVT_TX_DONE: { // 一次DMA发送完成 NRF_LOG_DEBUG(UART TX Done.); // 可以在这里设置一个标志通知主循环发送完成或者启动下一次发送 break; } case NRF_DRV_UARTE_EVT_RX_DONE: { // 一次DMA接收完成即接收缓冲区满了 uint32_t data_length p_event-data.rxtx.bytes; NRF_LOG_DEBUG(UART RX Done, received %d bytes., data_length); // 处理接收到的数据m_rx_buffer[0] 到 m_rx_buffer[data_length-1] process_rx_data(m_rx_buffer, data_length); // !!! 关键步骤处理完数据后必须重新启动接收 !!! nrf_drv_uarte_rx(m_uarte_instance, m_rx_buffer, sizeof(m_rx_buffer)); break; } case NRF_DRV_UARTE_EVT_ERROR: { // 发生错误如帧错误、奇偶校验错误等 NRF_LOG_ERROR(UART Error: 0x%08X, p_event-data.error.error_mask); // 错误发生后通常也需要重启接收 nrf_drv_uarte_rx(m_uarte_instance, m_rx_buffer, sizeof(m_rx_buffer)); break; } case NRF_DRV_UARTE_EVT_RX_TIMEOUT: { // 接收超时事件需要使能——这是实现不定长接收的“神器” uint32_t data_length p_event-data.rxtx.bytes; NRF_LOG_DEBUG(UART RX Timeout, received %d bytes before timeout., data_length); // 处理在超时前接收到的数据 process_rx_data(m_rx_buffer, data_length); // 重启接收 nrf_drv_uarte_rx(m_uarte_instance, m_rx_buffer, sizeof(m_rx_buffer)); break; } default: break; } }事件处理的核心逻辑UARTE的工作是“触发-回调”模式。你发起一个操作如rx或tx然后就去忙别的。操作完成后硬件会产生事件并调用你注册的回调函数。在RX_DONE或RX_TIMEOUT事件处理完后必须再次调用nrf_drv_uarte_rx来重启接收DMA否则串口将不再接收后续数据。这是一个经典的“乒乓缓冲区”或“循环接收”模式。4. 核心功能实现发送、接收与高级应用基础框架搭好了我们来实现具体的功能。4.1 实现数据发送阻塞与非阻塞发送数据相对简单。// 阻塞式发送简单但会卡住CPU void uart_send_blocking(uint8_t * data, uint16_t length) { ret_code_t err_code; err_code nrf_drv_uarte_tx(m_uarte_instance, data, length); APP_ERROR_CHECK(err_code); // 注意nrf_drv_uarte_tx 函数在默认配置下是阻塞的会等待发送完成才返回。 // 你可以通过配置 uarte_config.use_easy_dma_for_tx 来改变行为但通常阻塞式用于简单场景。 } // 非阻塞式发送推荐效率高 void uart_send_async(uint8_t * data, uint16_t length) { ret_code_t err_code; // 确保之前的发送已完成或者管理好发送缓冲区避免覆盖。 // 在实际项目中你需要一个发送队列FIFO来管理待发送数据。 while (nrf_drv_uarte_tx_in_progress(m_uarte_instance)) { // 等待上一次发送完成或者将数据放入队列 // 这里简单等待实际应用应使用队列状态机 __WFE(); // 等待事件进入低功耗模式 } err_code nrf_drv_uarte_tx(m_uarte_instance, data, length); APP_ERROR_CHECK(err_code); // 函数立即返回发送完成后会触发 EVT_TX_DONE 事件 }实操心得对于调试信息输出阻塞式发送问题不大。但对于主循环中需要频繁发送数据的场景如持续上报传感器数据一定要用非阻塞式并结合队列。否则一次长时间的发送比如发送一幅图片的二进制数据会阻塞整个系统影响其他任务如蓝牙连接的实时性。4.2 实现数据接收定长与不定长接收是串口编程的重点和难点。定长接收如果你知道每次数据包的确切长度比如固定20字节那么就在RX_DONE事件中处理。当DMA收满指定长度后触发该事件。不定长接收更常见例如接收一条以换行符\n结尾的文本命令。这就需要用到RX_TIMEOUT事件。启用并配置RX_TIMEOUT在初始化配置中需要额外设置超时参数。void uart_init_with_timeout(void) { ret_code_t err_code; nrf_drv_uarte_config_t config NRF_DRV_UARTE_DEFAULT_CONFIG; // ... 配置引脚、波特率等 ... // 关键使能并设置RX超时 config.timeout_enable true; // 使能超时功能 config.timeout_value 20; // 超时时间单位是“字符时间”。115200波特率下1个字符时间约87us。20个即约1.74ms。 err_code nrf_drv_uarte_init(m_uarte_instance, config, uart_event_handler); APP_ERROR_CHECK(err_code); nrf_drv_uarte_rx(m_uarte_instance, m_rx_buffer, sizeof(m_rx_buffer)); }工作原理UARTE在接收到第一个字节后启动一个定时器。如果在timeout_value个字符时间内没有收到新的字节就会产生RX_TIMEOUT事件并将当前已接收的字节数通过p_event-data.rxtx.bytes传递出来。这样你就得到了一个“数据包”无论它多长只要不超过缓冲区大小。处理不定长数据例如以换行符结尾在process_rx_data函数中void process_rx_data(uint8_t * data, uint16_t length) { static uint8_t packet_buffer[512]; // 静态缓冲区用于组装完整数据包 static uint16_t packet_index 0; for (int i 0; i length; i) { packet_buffer[packet_index] data[i]; // 检查是否收到包结束符例如换行符 \n if (data[i] \n) { // 找到完整数据包 packet_buffer[packet_index] \0; // 添加字符串结束符方便打印 NRF_LOG_INFO(Received Packet: %s, packet_buffer); // 这里可以调用协议解析函数 parse_command(packet_buffer, packet_index); // 重置索引准备接收下一个包 packet_index 0; } // 防止缓冲区溢出 if (packet_index sizeof(packet_buffer)) { NRF_LOG_WARNING(Packet buffer overflow!); packet_index 0; // 丢弃旧数据重新开始 } } }4.3 重定向printf到串口RTT替代方案虽然Segger RTT是更好的调试工具但很多时候我们仍然习惯用printf。在NRF SDK中实现printf重定向需要重写_write系统调用。#include stdio.h #include stdint.h // 重写 low-level write 函数 int _write(int file, const char * p_char, int len) { (void) file; // 忽略file参数 if (len 0) { // 使用阻塞式发送确保调试信息完整输出 uart_send_blocking((uint8_t*)p_char, len); // 或者使用非阻塞式但要注意缓冲区管理 // uart_send_async((uint8_t*)p_char, len); } return len; }然后在main.c里你就可以直接使用printf(Value: %d\n, sensor_value);了。不过要注意printf函数本身比较耗时且会使用较多栈空间在中断服务程序ISR中要慎用。5. 稳定性与性能优化实战让串口稳定可靠地工作需要处理一些边界情况和性能问题。5.1 缓冲区管理与流量控制接收缓冲区溢出这是最常见的问题。如果数据来得太快而你的process_rx_data函数处理太慢在下次nrf_drv_uarte_rx调用前新数据就会覆盖旧数据。解决方案使用双缓冲区Ping-Pong Buffer。定义两个接收缓冲区rx_buf_A和rx_buf_B。当RX_DONE或RX_TIMEOUT事件发生时处理当前满的缓冲区比如A同时立即用另一个空缓冲区B启动下一次接收。处理完A后交换角色。这需要更复杂的事件处理逻辑但能彻底避免溢出。发送阻塞与队列如前所述非阻塞发送需要队列。实现一个简单的环形队列FIFO主循环将要发送的数据放入队列。在EVT_TX_DONE事件中从队列取出下一包数据并启动发送。如果队列为空则等待。5.2 错误处理与恢复串口在复杂电磁环境中容易出错帧错误、噪声干扰。健壮的程序必须处理错误。case NRF_DRV_UARTE_EVT_ERROR: { uint32_t err_mask p_event-data.error.error_mask; if (err_mask NRF_UARTE_ERROR_OVERRUN_MASK) { NRF_LOG_ERROR(Overrun error: Data was received before previous data was read.); } if (err_mask NRF_UARTE_ERROR_PARITY_MASK) { NRF_LOG_ERROR(Parity error.); } if (err_mask NRF_UARTE_ERROR_FRAMING_MASK) { NRF_LOG_ERROR(Framing error: Stop bit not detected.); } if (err_mask NRF_UARTE_ERROR_BREAK_MASK) { NRF_LOG_WARNING(Break condition detected.); } // 错误发生后清空硬件错误标志并重启接收至关重要 nrf_drv_uarte_rx(m_uarte_instance, m_rx_buffer, sizeof(m_rx_buffer)); break; }5.3 低功耗设计考量NRF52840是低功耗芯片而UART外设在工作时功耗相对较高。动态开关UART如果不是一直需要串口通信可以在需要时初始化并启动接收在空闲一段时间后通过定时器调用nrf_drv_uarte_uninit关闭UARTE实例以省电。下次需要时再重新初始化。注意重新初始化后要重新配置引脚和启动接收。使用DMA的优势正因为UARTE使用了EasyDMA在数据搬运期间CPU可以进入睡眠模式__WFE()或__WFI()由DMA和UART硬件完成工作并在完成后通过中断唤醒CPU。这是实现系统级低功耗的关键。6. 调试技巧与常见问题排查即使按照指南做了第一次调不通也是常态。下面是一些快速定位问题的checklist。6.1 问题排查清单从硬件到软件物理连接TX和RX线是否接反了最常见共地GND连接了吗必须连接使用的是3.3V电平吗如果对方是5V必须加电平转换器。线缆是否完好换一根线试试。PC端配置设备管理器中看到的COM口号对吗串口调试助手的波特率、数据位8、停止位1、校验位None是否与代码设置完全一致115200和9600差一个数字都不行。串口助手是否成功打开了串口有的助手打开后需要按“启动”或“打开设备”。芯片端配置引脚配置是否正确用万用表测量一下在发送数据时TX引脚应该有电压跳变。或者用示波器看波形。时钟源对吗NRF52840的UART需要HFCLK高速外部时钟运行。确保在main()函数开始时调用了nrf_drv_clock_init()和nrf_drv_clock_hfclk_request(NULL)来请求HFCLK。UART初始化必须在HFCLK就绪之后。中断优先级冲突检查是否有更高优先级的中断长时间关闭了总中断导致UART中断无法响应。缓冲区太小如果发送大量数据确保发送缓冲区足够大或者使用循环发送。软件逻辑接收是否启动确认在初始化后调用了nrf_drv_uarte_rx。事件处理函数注册了吗检查nrf_drv_uarte_init的第三个参数是否正确。在RX_DONE/RX_TIMEOUT事件后重启接收了吗这是最容易被遗忘的步骤会导致只收到第一包数据。printf重定向成功了吗尝试直接调用uart_send_blocking(test\n, 5)看串口助手能否收到“test”。如果能说明硬件和底层驱动是好的问题在printf重定向或你的应用逻辑。6.2 利用Logging系统辅助调试Nordic SDK提供了功能强大的NRF_LOG模块它可以通过RTT、UART等多种后端输出。在调试串口本身时可以先用RTT输出日志因为它不依赖串口来验证你的程序逻辑是否运行到UART初始化那一步。在sdk_config.h中配置#define NRF_LOG_BACKEND_UART_ENABLED 0 // 先关闭UART后端避免循环依赖 #define NRF_LOG_BACKEND_RTT_ENABLED 1 // 启用RTT后端然后在main()初始化时调用NRF_LOG_INIT()和NRF_LOG_DEFAULT_BACKENDS_INIT()。这样你就可以用NRF_LOG_INFO、NRF_LOG_DEBUG来打印信息到J-Link RTT Viewer独立于串口来监控程序状态。6.3 一个完整的、可复用的UARTE管理模块框架最后分享一个我项目中常用的、封装得比较好的UARTE模块头文件设计供你参考// uart_manager.h #ifndef UART_MANAGER_H #define UART_MANAGER_H #include stdbool.h #include stdint.h #define UART_RX_BUFFER_SIZE 512 #define UART_TX_QUEUE_SIZE 20 typedef void (*uart_rx_callback_t)(uint8_t *data, uint32_t length); void uart_manager_init(uart_rx_callback_t callback); bool uart_manager_send_async(uint8_t *data, uint16_t length); void uart_manager_process(void); // 在主循环中调用处理发送队列等任务 uint32_t uart_manager_get_tx_queue_count(void); #endif // UART_MANAGER_H对应的.c文件里实现了基于NRF_DRV_UARTE的初始化和事件处理。一个静态的发送环形队列。在EVT_TX_DONE中从队列取出下一个包发送。将接收到的数据通过回调函数传递给应用层。uart_manager_process函数检查发送队列状态如果发送器空闲且队列有数据则启动发送。这种封装将底层驱动细节隐藏起来为应用层提供了“发送数据”和“接收回调”两个简洁的接口大大提高了代码的可用性和可维护性。