AC63串口收发实战:从UART寄存器到中断接收
简介面向嵌入式开发者的杰理AC63平台串口收发参考实现围绕串口消息预定义、中断回调、DMA配置与收发测试展开源于实际SDK二次开发适合需要快速上手杰理蓝牙SoC串口通信的工程师也可作为蓝牙音频类项目串口调试的起点。压缩包整体仅5KB共3个文件涵盖工程源码、说明页面与配置辅助项结构精简便于逐文件核对与移植。目前已有241人学习下载。实现过程演示了从event.h预定义串口消息、注册my_rx_handler处理SYS_DEVICE_EVENT消息到新建uart_test编写接收中断回调与数据包解析、再通过sys_event_notify通知上层调用的完整链路my_uart_init中波特率、数据位、停止位及DMA内存设置均有明确参照收发函数my_uart_putbyte与my_uart_write可直接用于发送测试。直接在串口回调函数内处理数据包的做法凸显杰理SDK在事件驱动与数据通路上的灵活性有助于降低串口模块接入成本、缩短排错时间也便于在此基础上扩展自定义收发协议。1. 拿到AC63开发板先做什么串口收发为什么是第一个要跑通的demo做杰理AC63系列方案的人应该都有同感这颗芯片虽然主业是蓝牙音频但你拿到板子第一天真正想干的往往不是连蓝牙而是让串口吐出一行字。AC63串口收发实例的价值就在这儿——它用最小代码量把UART控制器、引脚复用、中断接收这几件事串联起来跑通之后你才有一个可靠的调试出口后面调音频、调协议栈、看异常复位都有地方说话。这个实例特别适合两类人一类是刚接手AC63项目、对SDK结构还不熟的新人另一类是已经在做产品但想要一个干净串口驱动直接塞进工程的熟手。它不依赖操作系统也不需要额外的协议栈本质就是裸机环境下把串口的发送和接收链路打通半天时间就能跑出效果。2. AC63的UART控制器与SDK里的代码层次2.1 先搞清楚芯片上有哪几个UART能用外设差异与引脚复用AC63系列不同封装和型号在UART资源上有差异常见的配置是有12个UART外设有的型号还能复用出多组引脚。动手前第一件事不是写代码而是翻开对应型号的数据手册确认三件事UART编号、可用引脚、引脚是否与I2C/PWM/SPI冲突。一般来说UART0默认用于下载和日志输出UART1可以留给业务通信但具体到某个封装引脚能不能拉出来全看复用表。外设编号常见用途与其它外设的冲突点是否建议用中断接收UART0烧录调试、日志输出常与下载模式引脚复用调试建议轮询或中断均可UART1业务数据通信、外设对接可能与PWM/SPI引脚冲突强烈建议中断FIFO引脚复用配置在做之前一定要对照当前板子的原理图确认TX和RX没有被拉去做别的功能。有些开发板丝印标注的是“UART_TX”但实际引脚配置还要额外打开一个IOMUX开关这个开关在SDK里往往不是一个函数而是某个寄存器位后面代码示例里会单独说明。2.2 SDK里为什么找不到“标准HAL”两种串口代码的取舍接触过主流ARM MCU的人可能习惯了一套标准外设库但杰理AC63的SDK不是这个风格。它的代码组织更贴近音频应用场景大量代码是事件回调加任务唤醒外设驱动散落在不同模块里串口相关代码往往被封装在调试组件中并不像STM32的HAL那样给你一个统一的UART_Init结构体。所以你会发现网上流传的AC63串口收发实例大多是自己基于寄存器或者SDK原有接口重新封装的一层。常见的做法有两种一种是在SDK现有串口调试接口之上做二次封装优点是快速、和系统时钟配置天然一致缺点是调试接口本身有自己的行为逻辑比如它可能默认把收到的数据丢给内部日志系统另一种是完全绕开调试组件直接操作UART寄存器自己管引脚、波特率、中断这种方式代码看着更“裸”但可控性最强。我一般建议项目初期选第二种因为你不需要花时间理解SDK调试组件的内部实现出了问题也更好排查。2.3 初始化前的三条硬性检查写初始化函数之前有三个检查每次必做。第一确认外设时钟已经打开AC63的外设时钟开关散落在系统控制寄存器里不打开的话后面寄存器写什么都没反应这是最容易被忽略的坑。第二确认引脚复用优先级同一个引脚可能同时接到UART和I2C控制器上复用配置的顺序决定了最终谁生效。第三确认波特率分频来源UART模块的时钟源未必等于系统主频很多AC63上UART的时钟来自独立分频器算波特率时用错频率会直接导致乱码。这三个检查做完你基本就理解了AC63串口底层的工作方式。接下来进入代码层面先把发送链跑通。3. 跑通发送链路寄存器版串口初始化和发送函数3.1 头文件里先定义好寄存器与引脚宏直接操作寄存器的方式看着简陋但在AC63这类SDK结构不统一的平台上反而最稳。我习惯把用到的寄存器地址、状态标志位、引脚编号放在一个单独的头文件里后续换型号时只改这个文件。以下代码是示意写法具体地址需要对照数据手册补齐。// uart_reg.h #ifndef UART_REG_H #define UART_REG_H #include stdint.h // 以下地址为示意不同型号请以数据手册实际地址为准 #define UART0_BASE 0x80100000UL #define UART0_DATA (*(volatile uint32_t *)(UART0_BASE 0x00)) #define UART0_STATUS (*(volatile uint32_t *)(UART0_BASE 0x04)) #define UART0_CTRL (*(volatile uint32_t *)(UART0_BASE 0x08)) #define UART0_BAUD (*(volatile uint32_t *)(UART0_BASE 0x0C)) // 状态寄存器标志位 #define UART_ST_TX_READY (1UL 0) // 发送缓冲可写 #define UART_ST_RX_DONE (1UL 1) // 接收到一个字节 #define UART_ST_ERR_OVERRUN (1UL 2) // 溢出错误 // 控制寄存器开关位 #define UART_CTRL_EN (1UL 0) #define UART_CTRL_RX_IRQ (1UL 1) // 引脚复用值按SDK中gpio头文件定义填入 #define PIN_UART0_TX 3 // 示意值 #define PIN_UART0_RX 4 // 示意值 #endif这段定义里寄存器地址用了统一基址加偏移的形式实际替换地址时一目了然。注意UART_ST_TX_READY这个标志在有些型号里表示“发送保持寄存器为空”有些表示“发送移位寄存器为空”含义差异会影响你是在发送一个字节后立即退出还是等待真正发完这一点最好对着手册确认。3.2 初始化函数拆解时钟、引脚复用、波特率三件事初始化函数的核心是三件事。第一打开UART外设时钟这步通常在系统初始化阶段已经做了但自己再确认一次没有坏处。第二配置引脚复用把TX设为输出、RX设为输入并选中UART功能。第三配置波特率和数据格式。波特率这里有个常见做法是查SDK的时钟头文件确认UART模块时钟频率之后再算分频值不要假设它等于系统主频。// uart_init.c #include uart_reg.h // 假设UART模块时钟为48MHz目标波特率115200 // 分频值 时钟频率 / 目标波特率 #define UART_CLK_FREQ 48000000UL #define UART_BAUD_RATE 115200UL #define UART_BAUD_DIV (UART_CLK_FREQ / UART_BAUD_RATE) void uart0_pin_init(void) { // 1. 配置TX方向为输出RX方向为输入 // 接口名以实际SDK头文件为准不同版本命名有差异 gpio_set_dir(PIN_UART0_TX, GPIO_DIR_OUT); gpio_set_dir(PIN_UART0_RX, GPIO_DIR_IN); // 2. 复用选择为UART功能这一步必须在方向配置之后 gpio_set_function(PIN_UART0_TX, GPIO_FUNC_UART0_TX); gpio_set_function(PIN_UART0_RX, GPIO_FUNC_UART0_RX); // 3. RX引脚内部上拉避免空闲时电平不确定 gpio_set_pull(PIN_UART0_RX, GPIO_PULL_UP); } void uart0_init(void) { uart0_pin_init(); // 关闭UART后配置避免配置过程中产生错误状态 UART0_CTRL 0; // 配置波特率分频 UART0_BAUD UART_BAUD_DIV; // 配置数据格式为8N1开启UART // 具体位域以手册为准这里示意为整体赋值 UART0_CTRL UART_CTRL_EN | (0x03 2); // 8位数据1停止位 // 等待发送通道就绪 while (!(UART0_STATUS UART_ST_TX_READY)); }这段代码的关键点是顺序。先配引脚方向再配复用功能因为有些AC63型号在切换复用时会顺带改变引脚默认方向如果反过来会导致TX方向被重置。波特率计算使用UART模块时钟而不是系统主频这是无数人乱码的根源。初始化的最后强制等待一次TX_READY目的是把发送状态机拉到一个确定起点避免上电瞬间发第一个字节时卡死。3.3 最小发送函数与main函数示例发送函数只需要两个动作等待发送缓冲可写然后往数据寄存器写字节。受限于AC63部分型号没有硬件FIFO一字节一等待的方式是最通用可靠的。#include uart_reg.h void uart0_send_byte(uint8_t byte) { // 等待发送缓冲为空防止丢数据 while (!(UART0_STATUS UART_ST_TX_READY)); UART0_DATA byte; } void uart0_send_string(const char *str) { while (str *str) { uart0_send_byte((uint8_t)(*str)); } } int main(void) { // 系统初始化部分省略具体以SDK启动流程为准 uart0_init(); while (1) { uart0_send_string(AC63 UART TX OK\r\n); // 延时若干毫秒避免刷屏 delay_ms(1000); } return 0; }while (!(UART0_STATUS UART_ST_TX_READY));这一行是整个发送函数的核心它可以保证前一个字节真正被外设取走之后才写入下一字节。很多人图快省略这行在低波特率下看不出问题一旦把波特率调到1M以上或者连续发送长字符串就会出现第一个字节丢失或者字节穿插错乱的现象。发送字符串函数不要在里面做复杂处理保持纯轮询这能为后面挪到中断环境留余地。4. 接收不能靠轮询中断接收与环形缓冲区4.1 中断方式接收的必要性串口接收如果写一个死循环轮询状态寄存器在裸机环境下勉强能用但AC63的典型应用是音频方案主循环里往往有音频数据搬运和协议处理轮询接收会让两个问题同时冒出来一是CPU空转浪费二是数据来了不能及时处理导致溢出。所以接收方向必须用中断加缓冲区的方式。原理并不复杂UART每接收到一个字节触发一次中断中断服务函数里把数据扔进一个环形缓冲区应用层需要时再来取。接收缓冲区用环形结构而不是普通数组原因是串口数据到达不均匀中断产生速度快、消费速度慢环形缓冲区天然解决了覆盖问题。大小按实际业务定一般调试用途256字节足够如果对接的是GPS或蓝牙透传这类持续数据流建议直接上1KB以上否则溢出丢数据是大概率事件。4.2 中断服务函数与环形缓冲实现下面的代码是一个完整的接收通路实现。环形缓冲区采用2的幂次大小取模运算可以写成位与操作省掉除法开销。中断服务函数要尽量短只做数据搬运和溢出标记不做协议解析。// uart_rx_ring.c #include uart_reg.h #define RX_RING_SIZE 256 #define RX_RING_MASK (RX_RING_SIZE - 1) static volatile uint8_t rx_ring[RX_RING_SIZE]; static volatile uint16_t rx_head 0; // 写索引中断里更新 static volatile uint16_t rx_tail 0; // 读索引应用层更新 void uart0_rx_isr(void) { uint32_t status UART0_STATUS; // 只要接收数据寄存器有数据就一直读到空为止 if (status UART_ST_RX_DONE) { uint8_t byte (uint8_t)UART0_DATA; // 读数据寄存器会清标志 uint16_t next (rx_head 1) RX_RING_MASK; if (next ! rx_tail) { rx_ring[rx_head] byte; rx_head next; } // 如果 next rx_tail 说明缓冲区满此处可以置一个溢出标志 } // 溢出错误处理读状态清除同时可以考虑丢弃数据 if (status UART_ST_ERR_OVERRUN) { // 清错误标志具体位见手册 UART0_STATUS status; } } void uart0_enable_rx_irq(void) { // 打开接收中断使能位 UART0_CTRL | UART_CTRL_RX_IRQ; // 中断服务函数注册动作由SDK的中断管理机制完成 // 通常是在system_init或外设初始化中调用 }中断服务函数里最容易搞错的是清标志的时机。AC63这类的典型设计是读数据寄存器即自动清接收标志所以无论如何都要把UART0_DATA读出来哪怕这次数据不要。如果只清状态寄存器而不读数据中断标志一直有效会进死循环。另外环形缓冲区的读写索引一个只在中断里改、一个只在应用层改看似有竞态风险在单核裸机环境下只要保证中断不嵌套这个设计就是安全的。4.3 应用层如何读取接收数据应用层读取接口要设计成“无数据立即返回”的形式方便在主循环里非阻塞地查询。下面这个函数每次取一个字节返回0表示无数据。int uart0_read_byte(uint8_t *out_byte) { if (rx_head rx_tail) { return 0; // 缓冲区空 } *out_byte rx_ring[rx_tail]; rx_tail (rx_tail 1) RX_RING_MASK; return 1; } // 使用示例主循环中轮询取数据 void app_loop(void) { uint8_t byte; if (uart0_read_byte(byte)) { // 在这里做数据组装和业务解析 process_received_byte(byte); } }中断推送、应用层拉取的结构让串口接收完全不阻塞主循环。注意这个读取函数返回的是“是否有数据”而不是“读取是否成功”看上去只是语义差别但在C语言里返回值充当多个角色时容易埋坑建议把取数据状态和实际数据分开传参。另外如果业务里需要一次读一帧数据可以在这个函数之上再做包尾判定不要在中断里做。5. 串口收发实例避坑手册现象、原因、解决5.1 三个肉眼可判的硬件层翻车现场第一个坑是波特率乱码串口助手能收到字符但全是乱码。这个现象十个有九个出在分频值计算上。多数人会拿系统主频去除以波特率但AC63的UART时钟往往不走系统主频那条路径而是独立分频。解决方法是去SDK头文件里找到UART模块时钟宏定义用它替换代码里的时钟频率常量重新计算分频值。还有一个小概率是串口助手的波特率选错但先检查代码永远是正确顺序。第二个坑是引脚配置不生效代码里设置了复用但信号就是出不来。最常见原因是引脚方向配置和复用配置的顺序反了部分AC63型号中切换复用功能会把GPIO方向寄存器复位到默认值。解决方法是把gpio_set_function放在gpio_set_dir之后调用如果还不行检查是否有第二层IOMUX开关没打开。第三个坑是中断使能后程序卡死在中断里。现象是程序跑起来后主循环不再执行调试器打断一看停在中断服务函数。原因基本可以锁定在读数据寄存器清标志这个环节。有些AC63型号的接收标志在读数据寄存器后自动清除有些需要额外写状态寄存器才能完成清除。解决方法是中断服务函数里先读数据再显式读一次状态寄存器确认标志状态排查时不要只看SDK给的示例以手册时序图为准。5.2 两个只在特定运行状态冒出来的深水坑第四个坑是发送长字符串时前几个字节正常、中间突然丢字节。这往往不是波特率问题而是发送循环里没有等待发送保持寄存器空。比如一次性往数据寄存器写了多个字节硬件缓冲装不下就丢弃。解决方法是每个字节写入前都检查TX_READY标志如果芯片支持发送FIFO可以改用FIFO深度作为等待条件。这个坑在调试时很难发现因为发送短消息根本碰不到缓冲上限。第五个坑是低功耗唤醒后串口再也发不出数据。AC63这类蓝牙芯片的低功耗管理单元会在进入sleep前关掉部分外设时钟唤醒后如果没有重新初始化串口状态机会停留在未知状态。现象是唤醒后调用发送函数TX_READY标志永远不置位。解决方法是注册低功耗回调在睡眠前保存UART寄存器状态、关闭接收中断唤醒后恢复时钟并重新执行初始化函数。不要觉得这是小概率问题做真机电池供电的蓝牙设备时几乎都会遇到。6. 进阶用法用回环自测与调试宏把串口变成开发台6.1 回环测试把TX短接到RX一脚踢出整条链路的问题代码都写完先别急着接外设拿一根杜邦线把UART的TX和RX短接然后发送一串数据同时用接收中断收回来再把收到的内容打印出去。如果发送什么收什么说明从寄存器到中断到缓冲区整条链路都是通的。这个测试成本为零却能把时钟、引脚、中断、缓冲区四个环节一次性验证完。回环测试的代码不需要单独实现把发送字符串函数后紧接一个读取循环设定超时时间即可。void uart0_loopback_test(void) { uint8_t echo_buf[64]; uint16_t len 0; uart0_send_string(HELLO_AC63\r\n); // 等待接收并回显超时防止死等 uint32_t timeout 100000; while (timeout-- len sizeof(echo_buf) - 1) { if (uart0_read_byte(echo_buf[len])) { len; } } echo_buf[len] \0; uart0_send_string(echo_buf); }回环通过之后再把杜邦线拆掉接真实设备一步步缩小排查范围。6.2 一个轻量调试打印宏给串口输出加开关和分级串口打通之后我习惯立刻加一组调试宏用条件编译开关控制日志输出等级。这样后续调音频模块、调协议栈时不用反复注释代码。// dbg.h #ifndef DBG_H #define DBG_H #include stdint.h #define DBG_LEVEL_NONE 0 #define DBG_LEVEL_INFO 1 #define DBG_LEVEL_DEBUG 2 // 编译时指定调试等级发布版本改成NONE即可 #define DBG_LEVEL DBG_LEVEL_DEBUG #if DBG_LEVEL DBG_LEVEL_INFO #define DBG_INFO(fmt, ...) printf(fmt, ##__VA_ARGS__) #else #define DBG_INFO(fmt, ...) #endif #if DBG_LEVEL DBG_LEVEL_DEBUG #define DBG_DEBUG(fmt, ...) printf(fmt, ##__VA_ARGS__) #else #define DBG_DEBUG(fmt, ...) #endif #endif这个宏定义配合重定向的printf实现在发布版本时把DBG_LEVEL改成NONE调试代码一行都不用删。注意如果项目里没有重定向printf到串口直接改成调用前面写的uart0_send_string即可。6.3 串口与蓝牙协议栈共存的一个习惯AC63上做产品串口往往要跟蓝牙协议栈跑在同一颗芯片上。血的教训是中断服务函数里绝对不能做耗时操作比如往串口打印调试信息。蓝牙协议栈对中断延时极其敏感串口中断里一旦做过长的字符串格式化就会造成蓝牙事件处理超时。所以我在AC63方案上形成的基本习惯是串口中断只管进缓冲区所有数据解析和日志输出全部丢到主循环里做。另外如果业务数据吞吐量超过每秒1KB建议直接走DMA通道轮询和中断方式在持续大数据流场景下都会明显挤占CPU时间。这个习惯养成之后你会发现串口调试链路会变得非常安静主循环里采样性能数据、蓝牙异常日志、应用层状态都变得异常清晰。希望这个AC63串口收发的方案梳理能帮你在新项目里少走两步弯路。我自己的习惯是每换一颗AC63型号先把寄存器头文件和波特率时钟来源核对一遍再跑一次回环测试确认环境没问题才继续往上叠功能。这个顺序看起来慢但确实是我做过那么多方案里最省时间的路径。本文还有配套的精品资源点击获取