做杰理AC63项目的串口通信,最坑的从来不是UART协议本身,而是你把代码写完、板子接好,却发现串口调试助手上面要么一片空白,要么全是乱码。很多刚接触杰理平台的开发者,沿用STM32那套思路在AC63上写串口,结果连日志都打不出来。这篇文章记录的是我从零开始,在AC63核心板上打通串口通信、实现数据双向收发的完整过程,包括SDK初始化、引脚复用、中断接收、环形缓冲区设计和实测踩坑,关键代码段会逐一拆开讲。适合手里有AC63开发板、想做蓝牙透传或者调试日志输出,但被官方SDK绕得头晕的人参考。
1. AC63的串口资源与硬件接线:先解决“线”的问题
1.1 AC63的UART外设到底有几个,能干什么
AC63系列是杰理面向低功耗蓝牙音频市场的一颗SoC,常见的如AC630N、AC631N、AC632N,集成蓝牙双模协议栈,CPU用的RISC-V内核,Flash和RAM都比较紧凑。正因为资源不算宽裕,串口这个看似简单的模块,在AC63上反而需要仔细规划。
一般AC63会提供至少两路UART资源:一路经常被SDK用作日志输出口,另外一路可以留给业务逻辑做数据通信。拿我手头的这颗AC6328S为例,它在GPIO上同时映射了UART0和UART1,支持常见的8N1格式,波特率可以配置到最高1Mbps以上,具体上限要看系统时钟和分频参数,手册里一般会给出最大波特率表格。注意,不是所有GPIO都能复用成UART引脚,每个引脚的可复用功能是固定的,接错引脚、复用了错误的功能编号,代码怎么调都白搭。
我建议拿到SDK的第一步不要急着写应用,先去SDK的gpio.h和uart.h头文件里翻一下引脚复用表,确认你板子上实际引出的TX和RX分别属于哪组UART。很多开发板的丝印标注和SDK默认的log口不一致,先确认这一点能省掉后面两个小时。
1.2 硬件接线:电平、共地、交叉连接缺一不可
串口通信的硬件接线看起来简单,实际上有三个非常容易忽略的点:
- 电平匹配:AC63的UART引脚是3.3V电平,如果你的USB转串口模块不是3.3V供电而是5V供电,尤其某些便宜模块在5V模式下TX引脚输出高电平接近5V,长期使用可能损伤AC63引脚。优选支持3.3V电平的模块,或者确认模块的VCC跳线帽在3.3V档位。
- TX接RX、RX接TX:必须交叉连接。开发板上的UART_TX要接转换模块的RX,UART_RX要接模块的TX。反了的表现通常是:你发数据它没反应,但模块的TX/RX灯还在闪。
- 共地:开发板和USB转串口模块必须共地,GND不接,收发大概率是乱码或者完全没数据。这个新手经常漏。
我实测中比较稳的连接方式是:AC63核心板通过板载的USB转串口(如果有)直接连电脑,省去外部接线;如果是裸板,用CP2102或CH340G模块,接VCC、GND、TX、RX四个脚,把串口助手波特率设为115200,其余默认。这么接完,硬件部分就算通了。
2. 开发环境与最小工程:先让串口“开口说话”
2.1 工具链准备:JieLi IDE、SDK包和编译链
杰理AC63的开发环境用的是官方提供的JieLi IDE,基于Eclipse二次开发,内置了RISC-V交叉编译链和烧录工具。也有部分工程直接用Makefile配合命令行编译,视SDK版本而定。我第一次用的时候走了一点弯路:以为跟STM32一样装个Keil就能搞,结果AC63的SDK根本不支持Keil,老老实实装了官方IDE。
工具链清单大致是:
- JieLi IDE(对应你芯片型号的版本)
- AC63xx SDK包(建议用和芯片型号匹配的release版本)
- USB下载/调试线(通常是USB转SPI或者专用烧录器,看开发板配置)
- USB转串口模块或板载串口
SDK解压后,目录结构一般包含apps、include、src、ld等,应用代码主要在apps目录下。不同版本的目录名略有差异,但大体逻辑一致。建议不要直接用空工程从零开始,而是拷贝SDK里最接近的demo工程改,因为AC63的工程依赖很多系统配置和链接脚本,从零手写很容易漏配置。
2.2 改log口:最快验证串口通路的方式
先把SDK自带的日志功能打开。AC63的log口本质上也是一个UART,SDK会初始化一个调试串口来输出printf内容。如果你板子上的log口引脚和SDK默认配置不一致,就需要改引脚复用。
拿我用的SDK版本为例,日志串口的初始化逻辑大致这样:
// log_init 内部会配置uart引脚和波特率 // 不同SDK版本函数名不一样,常见的形式是: void log_init(void) { uart_init(LOG_UART_ID, 115200); // 重定向printf到该uart }改完引脚和波特率后,编译烧录,如果开发板上有LED周期性闪烁或者某些启动打印,串口助手能看到日志,说明log通路已经通了。到这里,串口“开口说话”的第一步就算完成。
如果log口怎么都出不来,按这个顺序排查:
- 串口助手波特率是否和代码里一致,别115200和9600来回试了半天。
- USB转串口的驱动是否正常,打开设备管理器看COM口号。
- 引脚复用是否配置正确,查SDK的gpio复用表。
- 确认开发板供电正常,AC63进入运行状态。
这个阶段不需要写任何业务代码,纯粹验证环境,但很多人就在这一步卡住,所以别跳过。
3. 串口数据收发的完整实现:发送、接收与环形缓冲
3.1 初始化流程:时钟、GPIO复用、UART参数、中断注册
业务UART不能跟log口冲突。如果你用UART0做log,那么业务数据就放到UART1,或者反过来。下面的初始化代码以UART1为例,按“开时钟、配引脚、设参数、挂中断”四步走。
#include "uart.h" #include "gpio.h" #define BUSSINESS_UART_ID 1 #define UART1_TX_PIN GPIO_PIN_2 #define UART1_RX_PIN GPIO_PIN_3 void app_uart1_init(uint32_t baud) { // 1. 使能UART1时钟(部分SDK在uart_init里已经做了,可省略) uart_clk_enable(BUSSINESS_UART_ID); // 2. 配置TX、RX引脚复用 gpio_set_fun(UART1_TX_PIN, GPIO_FUN_UART1_TX); gpio_set_fun(UART1_RX_PIN, GPIO_FUN_UART1_RX); // 有些板子需要额外配置上下拉,RX建议加上拉,避免悬空误触发 gpio_set_pull_up(UART1_RX_PIN, 1); // 3. 设置波特率、数据位、停止位、校验位 uart_init(BUSSINESS_UART_ID, baud); // 默认8N1 uart_set_baudrate(BUSSINESS_UART_ID, baud); // 4. 注册接收中断回调(中断里只做数据搬运,不要做耗时处理) uart_set_rx_callback(BUSSINESS_UART_ID, uart1_rx_handler); uart_irq_enable(BUSSINESS_UART_ID); }这段代码的重点在于操作顺序。有些开发者习惯先初始化UART再配置GPIO,这在某些MCU上没问题,但是AC63的引脚复用优先级较高,如果复用没配置好,UART外设发出的信号根本到不了引脚上。另外,RX引脚加上拉是实战经验:悬空的RX容易被环境噪声干扰,产生一连串0x00、0xFF之类的伪数据。
3.2 发送路径:阻塞发送与数据搬运的取舍
发送最简单的方式是轮询查询发送寄存器空闲,然后一字节一字节往外丢。在AC63这种资源不算充裕的平台上,如果只是发少量调试信息,这种方式完全够用,代码也直观:
void uart1_send_byte(uint8_t data) { while (uart_tx_busy(BUSSINESS_UART_ID)); // 等待发送寄存器空闲 uart_write_byte(BUSSINESS_UART_ID, data); } void uart1_send_buf(uint8_t *buf, uint16_t len) { for (uint16_t i = 0; i < len; i++) { uart1_send_byte(buf[i]); } }这里必须解释一个我实际踩过的坑:不要在主循环里频繁调用这种阻塞发送,去发送大块数据。原因是UART波特率决定了字节发送速度,115200波特率下每秒最多约11.5KB,发送1KB数据需要差不多100ms,期间CPU全部空转在while循环里。如果你的主循环还要处理蓝牙协议栈,这100ms的阻塞会导致协议栈喂狗超时、连接断开等问题。
所以实际项目中,如果数据量稍大,我更推荐做一个简单的发送缓冲队列,把待发送的数据放进队列,然后在UART发送完成中断里逐个取出发送,让CPU在等待期间可以干别的事。
3.3 接收路径:为什么必须用环形缓冲区
接收是串口开发里最容易出bug的地方。如果只用单个字节变量接数据,只处理一个字节还好,但一旦对端发来一帧几十字节的数据,单字节方案要么丢字节,要么忙不过来。经典解法是环形缓冲区(Ring Buffer)。
它的思路说白了就是一块固定大小的内存,用两个指针分别记录“读到哪里”和“写到哪里”,写指针由中断服务函数推进,读指针由主循环业务代码推进,二者互不干扰。当写指针追到读指针时,说明缓冲区满了,此时根据策略可以丢弃旧数据或丢弃新数据。
#define RX_BUF_SIZE 512 static uint8_t rx_ring_buf[RX_BUF_SIZE]; static volatile uint16_t rx_head = 0; // 写指针,中断里更新 static volatile uint16_t rx_tail = 0; // 读指针,主循环里更新 static uint16_t rx_ring_count(void) { return (uint16_t)(rx_head - rx_tail); } // UART接收中断回调,中断上下文,尽量精简 void uart1_rx_handler(void *priv) { uint8_t data; while (uart_read_byte(BUSSINESS_UART_ID, &data) == 0) { // 读成功返回0 uint16_t next = (uint16_t)((rx_head + 1) % RX_BUF_SIZE); if (next != rx_tail) { // 缓冲区未满 rx_ring_buf[rx_head] = data; rx_head = next; } // 满了就丢,实际项目可以置溢出标志 } } // 主循环/业务逻辑调用,取走一个字节 uint8_t uart1_get_char(uint8_t *byte) { if (rx_head == rx_tail) { return 1; // 无数据 } *byte = rx_ring_buf[rx_tail]; rx_tail = (uint16_t)((rx_tail + 1) % RX_BUF_SIZE); return 0; }这段代码里要注意三点:
- 中断里不要做耗时的数据处理,比如解析协议、回调业务逻辑。中断里干得越少越好,数据先放到缓冲区,业务逻辑在主循环里处理。我在最初版本里直接把协议解析写进了中断,结果波特率高的时候中断时间过长,直接影响蓝牙协议栈时序。
- 读写指针用了volatile,因为读指针在主循环里改,写指针在中断里改,编译器优化时可能把频繁访问的变量优化到寄存器里,导致一边看不到另一边的更新。volatile能防止这种问题。
- 取模操作
(rx_head + 1) % RX_BUF_SIZE在缓冲区大小为2的幂时可以改成位运算(rx_head + 1) & (RX_BUF_SIZE - 1),效率高一些。AC63的主频不高,能在中断里省一点是一点。
接收中断回调在SDK里不同的版本名字不一样,有的叫uart_rx_cb,有的需要自己在中断向量里注册,但本质都是“有数据来了就调这个函数”。你可以照着把手里的SDK对应函数名替换进去。
3.4 Echo回环测试:验证数据通路是否完整
初始化好UART,写完收发函数,我建议先做一个最简单的echo测试:串口发什么,AC63就回什么。这样只要串口助手里能看到自己发的内容原样返回,整条数据链路就是通的。
void uart1_task(void) { uint8_t ch; if (uart1_get_char(&ch) == 0) { uart1_send_byte(ch); // 原样回显 } }把这个任务放进主循环,波特率设置为115200,串口助手里输入“ABC123”,正常回显“ABC123”。如果回显出现丢字符、多字符、乱码,就进入下一步排查。
4. 参数调整、乱码排查与信号验证:实测联调高频踩坑
这一节是真正的实战部分。我把联调过程中遇到的高频问题按现象分类,用表格列出来,再逐个说排查思路。这些问题在AC63上我都实际遇到过,包括一些折腾了半天的。
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 完全无数据,TX/RX灯不闪 | 引脚复用错误、接线交叉错、串口未初始化 | 检查GPIO复用表,确认TX接对方RX,示波器量引脚电平 |
| 完全无数据,但TX/RX灯闪 | 波特率不匹配、芯片处于低功耗模式 | 串口助手换常见波特率逐一试;确认系统没有进入睡眠 |
| 出现连续乱码 | 波特率偏差过大、地线没接、外部晶振频率和SDK配置不一致 | 用示波器量单字节发送波形,计算实际波特率 |
| 能收不能发 | TX引脚复用没配置、发送阻塞在busy判断上 | 量TX引脚是否正常翻转;检查发送中断是否误占用 |
| 能发不能收 | RX引脚没配复用、接收中断未使能、回调函数未注册 | 检查RX引脚复用;确认回调注册执行了 |
| 丢字节或偶发乱码 | 中断里耗时太长、缓冲区太小、FIFO溢出 | 缩短中断处理逻辑,加大环形缓冲区;开启UART FIFO前确认溢出标志 |
4.1 乱码问题的根因:不仅仅是波特率
乱码是串口开发里最常见的问题。很多人第一反应是波特率不对,但实际上AC63平台上乱码还有一个根因容易被忽略——外部晶振频率和SDK系统时钟配置不匹配。
AC63的UART波特率是由系统时钟分频得到的,如果SDK配置的系统时钟是96MHz,但板子实际焊的是24MHz晶振,或者反过来,UART产生的波特率和设置值会有明显偏差。对于115200波特率,如果分频误差超过2%,就会出现间歇性乱码,严重时全乱。
我遇到过一次比较棘手的情况:板子用外部24MHz晶振,但SDK的时钟初始化代码默认开了内部高频RC或者锁相环倍频,导致串口输出字符中前几个字节能识别、后半段乱码。后来用示波器测量发现UART TX引脚的单bit宽度比理论值窄了约5%,确认是时钟源配置问题。
所以排查乱码的顺序建议是:
- 先用示波器或逻辑分析仪抓取TX引脚波形,测量一个起始位加8个数据位的时宽,反推实际波特率。
- 如果实际波特率和设定值偏差较大,检查SDK的系统时钟初始化配置,确认外部晶振、PLL倍频系数正确。
- 如果波形正常,仍然乱码,再怀疑USB转串口模块本身,或者换一个模块验证。
- 检查两端的数据格式是否一致:数据位8位还是9位、停止位1位还是2位、是否带校验。这个因素在AC63上同样重要,两端配置不一致,5%的机率乱码,95%的机率完全读不出。
4.2 用示波器给串口数据“验明正身”
很多人手上没有示波器,但排查串口问题最好的工具恰恰是示波器或逻辑分析仪。不说测高级协议,只需要把探头夹在UART TX引脚和GND上,让设备持续发送0x55(二进制01010101),这时波形应该是标准的方波。
UART空闲时是高电平,发送一个字节时先拉低一个bit宽度的起始位,然后依次输出8个数据位,最后拉高一个bit宽度的停止位。通过测量一个bit的宽度,就可以算出实际波特率:
实际波特率 = 1 / 单个bit时间宽度假设设置115200波特率,理论单bit时间约为8.68us。如果示波器上量出来是9.5us或者7.8us,说明波特率已经偏差很大了,这时候乱码是必然的。
我一般会在产品联调阶段把下面的测试程序烧进去,循环发送0x55和0xAA,方便抓波形:
while (1) { uart1_send_byte(0x55); delay_ms(2); uart1_send_byte(0xAA); delay_ms(2); }0x55和0xAA作为交替的0101和1010,能最快暴露波特率误差。波形每bit的宽度均匀且接近理论值,硬件层就过关了。
4.3 中断里绝对不能做的事
串口接收中断的处理是丢数据的重灾区,尤其AC63这种带蓝牙协议栈的SoC,中断优先级和时间约束很敏感。我在排查一个丢帧问题时,发现中断里做了一件事很耗时:解析GPS协议并更新OLED显示。波特率9600下每帧数据约100字节,中断里全部处理完要好几毫秒。结果就是蓝牙协议栈任务被挤占,出现周期性卡顿,同时串口缓冲区频繁溢出。
正确做法是:
- 中断里只做“搬数据”,把数据放进环形缓冲区。
- 中断里只做状态机的最小一步,比如更新一个标志位。
- 协议解析、数据分析、显示刷新全部放到主循环、单独任务或者空闲回调里做。
一句话总结:中断是数据入口,不是数据加工车间。
另外一个容易被忽略的点是,AC63的UART接收FIFO可能不止一级。如果SDK默认开启了FIFO且溢出中断没处理好,数据在FIFO满的时候会直接丢弃。我建议初期调试先把UART FIFO关掉,用裸中断+环形缓冲测试,等逻辑稳定后再开启FIFO提升吞吐。这样能把缓冲溢出问题隔离出来,避免“缓冲区溢出”和“FIFO溢出”两个问题混在一起。
5. 从“能用”到“好用”:DMA、低功耗与帧协议思路
串口收到数据、能发回去,这只是第一步。实际产品中还要考虑传输效率、功耗和协议可靠性。下面这几个点我在AC63项目里都实践过,简单分享一些思路。
5.1 什么时候值得上DMA
AC63的UART外设支持DMA搬运。DMA的好处是数据在内存和外设之间搬运不需要CPU干预,适合大量、连续的数据收发。比如通过蓝牙把手机APP传过来的大文件转发给外部MCU,这种情况用DMA能大幅降低CPU占用。
但是DMA不是银弹。DMA需要固定的接收缓冲区,对“不知道数据什么时候来、来多少字节”的串口接收场景,处理起来反而麻烦:要么用空闲中断配合DMA实现不定长接收,要么定长接收后自己拆分。对于大部分数据量不超过几百字节的交互协议,中断+环形缓冲已经足够,上DMA增加代码复杂度,收益不明显。
我建议的判断标准是:波特率超过460800,或者每秒钟收发数据量超过2KB,再考虑DMA;否则先用中断方案,简单稳定。
5.2 低功耗场景:串口怎么既省电又不漏数据
AC63是低功耗蓝牙SoC,产品如果是电池供电,串口就不能一直开着等数据。最常用的策略是:
- 平时不进睡眠,让RX引脚保持高电平,不使能UART接收中断;通过外部中断监测RX引脚的下降沿(起始位),有下降沿说明对端开始发数据了,这时唤醒内核、初始化UART、开始正常接收。
- 或者使用UART的空闲中断,在总线空闲一段时间后休眠,但是空闲中断仍然需要UART时钟保持运行,省电效果有限。
一个务实建议是:如果串口只在配对或者升级时使用,低功耗模式下直接把UART关闭,用GPIO外部中断唤醒后再初始化UART。这样最省电,代价是从唤醒到UART初始化完成需要一点时间,对端可能要重发一两个字节。
5.3 分包与粘包:一个简单的帧协议模板
串口收发数据时,如果对端发来的数据长度不定,且数据间隔不稳定,就会出现所谓的粘包和半包问题。产品级代码建议从一开始就定义帧协议,不要裸发裸收。
我常用的最简单帧格式是:
帧头(0xAA 0x55) + 长度(1字节) + 数据(N字节) + 校验和(1字节)接收端的状态机按“找帧头 -> 收长度 -> 收数据 -> 校验”推进,把完整的一帧数据组装好再交给业务解析。这种协议虽然带了4字节额外开销,但能解决绝大多数串口通信的边界问题。
enum { STATE_WAIT_HDR1, // 等待第一帧头 STATE_WAIT_HDR2, // 等待第二帧头 STATE_WAIT_LEN, // 等待长度 STATE_DATA, // 接收数据 STATE_CHECK // 校验 };简单说就是:中断把收到的字节喂给状态机,状态机攒够一帧后置一个frame_ready标志,主循环再去处理。用状态机的好处是逻辑清晰、可测试,而且不会因为某次数据残缺而崩溃。
5.4 当SDK的库函数是“黑盒”:反汇编定位寄存器配置
最后说一个老手才会用到的技巧。AC63的SDK有些底层驱动是以库文件形式提供的,头文件里只暴露了函数声明,源码却看不到。如果你想确认某个波特率下UART的寄存器配置是否正常,可以把编译好的elf文件拖进反汇编工具,找到对应的UART初始化函数,看它往哪些寄存器写了什么值。
比如用GDB或者riscv-objdump反汇编,可以看到类似这样的片段:
li a0, 0x40002000 ; UART基地址寄存器 li a1, 0x2d ; 分频值,表示对系统时钟的分频 sw a1, 0x0(a0) ; 写入波特率寄存器根据系统时钟和分频数就能反推实际波特率。这个方法在碰到“SDK配置了波特率但实测波形不对”的问题时非常有效,能绕过黑盒直接验证底层配置。当然,普通应用开发不需要走到这一步,但如果你要做产品级稳定性排查,这招值得掌握。
关于AC63的串口通信,我自己最深的体会是四个字:先简后繁。不要一上来就上DMA、上复杂协议,先把log打通,做echo回环,确认硬件的每一根线、每一个引脚复用都正确,再逐步加中断、加缓冲区、加协议解析。串口这个模块技术本身不难,难的是把环境、引脚、时钟、中断这四样东西同时搞对。踩过几次坑之后,你会发现调试效率反而高了,因为每次怀疑都是先查基础配置,而不是凭空猜代码。