在嵌入式开发中,串口通信是最基础也最常用的通信方式之一。K210作为一款RISC-V架构的AIoT芯片,与STM32这类传统MCU的通信需求非常普遍。我刚开始接触这两个芯片的通信时,也是从最简单的单字节传输入手,逐步过渡到复杂的数据包协议。
串口通信的本质就是通过TX(发送)和RX(接收)两根线,按照约定的波特率逐位传输数据。K210的UART模块支持全双工通信,这意味着它可以同时发送和接收数据。在实际项目中,我通常会先用115200的波特率进行测试,这个速率既能保证传输效率,又不容易出现时序问题。
硬件连接很简单:K210的TX接STM32的RX,K210的RX接STM32的TX,两边的GND一定要接在一起。这里有个容易踩的坑:有些开发板的3.3V和5V电平不兼容,需要特别注意。我曾经因为电平不匹配导致数据乱码,排查了半天才发现问题。
让我们从最简单的单字节发送开始。在K210上使用串口需要先映射引脚,这是与其他MCU不同的地方。我通常会这样初始化:
python复制from machine import UART
from fpioa_manager import fm
# 引脚映射
fm.register(6, fm.fpioa.UART1_RX, force=True)
fm.register(7, fm.fpioa.UART1_TX, force=True)
# 初始化UART1
uart = UART(UART.UART1, 115200, 8, 0, 1, timeout=1000)
这里fm.register()函数将物理引脚6和7映射为UART1的RX和TX功能。force=True参数确保即使引脚已被占用也会强制重新映射。初始化UART时,我习惯设置1秒的超时时间,避免程序卡死在读取操作上。
发送单个字符'1'的代码非常简单:
python复制while True:
if uart.any(): # 检查是否有数据
uart.read() # 清空接收缓冲区
uart.write('1') # 发送字符'1'
STM32端的初始化要复杂一些,需要配置GPIO和USART外设。以下是我常用的初始化代码:
c复制void USART3_Init(uint32_t baudrate)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
USART_InitTypeDef USART_InitStruct = {0};
// 使能时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART3, ENABLE);
// 配置TX引脚(PB10)
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStruct);
// 配置RX引脚(PB11)
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_11;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOB, &GPIO_InitStruct);
// 配置USART3
USART_InitStruct.USART_BaudRate = baudrate;
USART_InitStruct.USART_WordLength = USART_WordLength_8b;
USART_InitStruct.USART_StopBits = USART_StopBits_1;
USART_InitStruct.USART_Parity = USART_Parity_No;
USART_InitStruct.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
USART_Init(USART3, &USART_InitStruct);
// 使能USART3
USART_Cmd(USART3, ENABLE);
}
接收单个字符的中断处理函数如下:
c复制void USART3_IRQHandler(void)
{
if(USART_GetITStatus(USART3, USART_IT_RXNE) != RESET)
{
uint8_t ch = USART_ReceiveData(USART3);
if(ch == '1')
{
// 收到字符'1'后的处理逻辑
}
}
}
单字节通信虽然简单,但在实际项目中有其应用场景。比如控制LED开关、电机启停等简单指令。不过当需要传输更复杂的数据时,这种方式就显得力不从心了。
在实际项目中,我发现单字节通信存在几个严重问题:首先是数据可靠性无法保证,一个字节出错整个通信就乱套了;其次是功能扩展性差,想传输坐标、ID等复杂数据几乎不可能;最后是缺乏校验机制,无法发现传输错误。
于是我开始设计自定义通信协议。一个好的协议应该包含以下要素:
经过多次迭代,我最终采用的协议格式如下:
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 帧头 | 1 | 固定为0x24('$') |
| 长度 | 1 | 数据部分的总长度 |
| 类编号 | 1 | 区分不同功能 |
| 类组 | 1 | 功能分组 |
| 数据量 | 1 | 实际数据项数量 |
| 数据 | N | 实际数据,用逗号分隔 |
| CRC | 1 | 校验和(前面所有字节的和模256) |
| 帧尾 | 1 | 固定为0x23('#') |
这种设计有几个优点:首先是可扩展性强,通过类编号和类组可以定义多种功能;其次是数据格式灵活,可以传输整数、字符串等多种类型;最后是校验机制简单有效。
在K210端,我编写了一个send_data函数来打包数据:
python复制def send_data(x, y, w, h, msg=None):
start = 0x24 # 帧头'$'
end = 0x23 # 帧尾'#'
length = 5 # 基础长度
class_num = 0x05 # 类编号
class_group = 0xBB # 类组
data_num = 0 # 数据量
crc = 0 # 校验和
data = [] # 数据列表
# 打包x,y,w,h四个16位整数(小端模式)
for value in [x, y, w, h]:
low = value & 0xFF
high = (value >> 8) & 0xFF
data.extend([low, 0x2C, high, 0x2C]) # 0x2C是逗号
# 打包字符串消息
if msg:
for ch in msg:
data.append(ord(ch))
data.append(0x2C)
# 计算数据量和总长度
data_num = len(data)
length += data_num
# 构建完整数据包
packet = [length, class_num, class_group, data_num] + data
# 计算CRC
for byte in packet:
crc += byte
crc %= 256
# 插入帧头和帧尾
packet.insert(0, start)
packet.append(crc)
packet.append(end)
# 发送数据
uart.write(bytearray(packet))
这个函数可以将坐标(x,y)、尺寸(w,h)和字符串消息打包成一个完整的数据包。我在实际项目中用它来传输物体检测结果,效果很好。
STM32端的解析要复杂一些,需要状态机来处理:
c复制typedef struct {
uint8_t class_n;
uint16_t x, y, w, h;
uint16_t id;
char msg[20];
} K210_MSG;
K210_MSG k210_msg;
uint8_t recv_buf[100];
uint8_t recv_index = 0;
uint8_t packet_len = 0;
uint8_t crc_sum = 0;
uint8_t recv_state = 0; // 0:等待帧头 1:接收数据 2:完成
void parse_k210_data(uint8_t ch)
{
static uint8_t data_index = 0;
switch(recv_state)
{
case 0: // 等待帧头
if(ch == 0x24) // '$'
{
recv_state = 1;
recv_index = 0;
crc_sum = 0;
}
break;
case 1: // 接收数据
recv_buf[recv_index++] = ch;
crc_sum += ch;
if(recv_index == 1) // 第一个字节是长度
{
packet_len = ch;
}
else if(recv_index == packet_len + 1) // 收到完整包
{
recv_state = 2;
}
break;
case 2: // 校验并处理
if(ch == 0x23) // '#'
{
uint8_t recv_crc = recv_buf[packet_len];
if((crc_sum - recv_crc) % 256 == recv_crc)
{
process_packet();
}
}
recv_state = 0;
break;
}
}
void process_packet()
{
uint8_t *p = recv_buf;
k210_msg.class_n = p[1]; // 类编号
k210_msg.x = (p[5]<<8) | p[3]; // x坐标
k210_msg.y = (p[9]<<8) | p[7]; // y坐标
k210_msg.w = (p[13]<<8) | p[11]; // 宽度
k210_msg.h = (p[17]<<8) | p[15]; // 高度
// 提取字符串消息
uint8_t msg_start = 19;
uint8_t i = 0;
while(msg_start < packet_len-1 && i < sizeof(k210_msg.msg)-1)
{
if(p[msg_start] != 0x2C) // 跳过逗号
{
k210_msg.msg[i++] = p[msg_start];
}
msg_start++;
}
k210_msg.msg[i] = '\0';
}
在USART中断中调用parse_k210_data函数:
c复制void USART3_IRQHandler(void)
{
if(USART_GetITStatus(USART3, USART_IT_RXNE) != RESET)
{
uint8_t ch = USART_ReceiveData(USART3);
parse_k210_data(ch);
}
}
在实际项目中,我总结出单字节通信和自定义协议各自的适用场景:
| 特性 | 单字节通信 | 自定义协议 |
|---|---|---|
| 实现复杂度 | 简单 | 复杂 |
| 数据传输量 | 极小 | 大 |
| 可靠性 | 低 | 高 |
| 功能扩展性 | 差 | 好 |
| 适用场景 | 简单控制指令 | 复杂数据传输 |
| 抗干扰能力 | 弱 | 强 |
| 开发周期 | 短 | 长 |
对于只需要发送简单指令的场景,比如控制LED灯开关,单字节通信完全够用。但在需要传输传感器数据、图像坐标等复杂信息时,自定义协议的优势就非常明显了。
我在一个智能小车项目中同时使用了两种方式:用单字节通信控制电机启停,用自定义协议传输摄像头识别到的目标位置。这种混合使用的方式既保证了简单控制的实时性,又满足了复杂数据的传输需求。
在调试K210与STM32的串口通信时,我遇到过不少问题,这里分享几个典型的:
数据乱码
通常是波特率不匹配或时钟配置错误导致的。建议先用示波器测量实际波特率,确保两边一致。我曾经因为STM32的时钟树配置错误,导致实际波特率偏差很大。
数据丢失
当传输大量数据时,可能会因为缓冲区溢出导致丢失。解决方法是在STM32端增大接收缓冲区,或者在K210端分片发送。我在协议中加入了长度字段,就是为了方便分片处理。
校验失败
CRC校验失败可能是数据传输过程中受到干扰。除了检查硬件连接外,还可以在软件上增加重传机制。我的做法是连续3次校验失败后请求重发。
调试时可以先用串口助手监控通信过程,确认数据格式正确后再进行芯片间通信。另外,在协议设计初期就加入调试信息字段会很有帮助,比如可以在数据包中加入序列号,方便跟踪每个包的传输情况。
经过多个项目的实践,我总结出几点优化建议:
合理设置波特率
对于简单的控制指令,115200波特率足够使用。但当需要传输大量数据时,可以考虑提高到921600甚至更高。不过要注意,波特率越高,对硬件的要求也越高。
优化数据处理流程
在STM32端,避免在中断服务程序中进行复杂处理。我的做法是在中断中只接收数据,在主循环中处理数据包。这样可以避免阻塞串口中断。
使用DMA传输
对于大数据量传输,可以启用UART的DMA功能。这样能大大减轻CPU负担,特别是在STM32端。我在处理图像数据时就采用了DMA方式。
协议压缩
当传输的数据有规律时,可以考虑增加压缩算法。比如连续的温度数据可以使用差分编码,能有效减少数据量。
双缓冲机制
在K210端实现双缓冲机制:一个缓冲区用于填充数据,另一个用于发送。这样可以提高通信效率,避免等待发送完成的时间浪费。