在嵌入式开发中,串口通信是最基础也最常用的调试手段之一。对于STM32开发者来说,如何快速搭建一个稳定可靠的串口通信框架,往往是项目开发的第一步。本文将基于STM32F103VET6芯片,分享一个完整的串口调试工程实践,涵盖从printf重定向到中断接收的全套解决方案。
这个工程模板特别适合那些已经了解STM32和USART基础,但在实际项目中需要快速搭建稳定串口通信框架的开发者。我们将重点讲解如何将标准库的printf/scanf函数重定向到串口进行调试输出,以及如何构建一个健壮的中断接收服务函数来处理不定长数据。
在开始之前,我们需要准备好开发环境。我推荐使用Keil MDK作为开发工具,因为它对STM32系列的支持非常完善。硬件方面,除了STM32F103VET6开发板外,你还需要一个USB转TTL模块用于连接电脑。
必备软件工具:
提示:在开始编码前,建议先通过STM32CubeMX生成基础工程框架,这样可以避免很多底层配置错误。
STM32F103VET6有多个USART接口,我们以USART1为例进行配置。USART1的TX引脚是PA9,RX引脚是PA10。以下是完整的初始化代码:
c复制void USART1_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
USART_InitTypeDef USART_InitStruct = {0};
NVIC_InitTypeDef NVIC_InitStruct = {0};
// 使能GPIOA和USART1时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE);
// 配置USART1 TX (PA9)为复用推挽输出
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStruct);
// 配置USART1 RX (PA10)为浮空输入
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStruct);
// USART参数配置
USART_InitStruct.USART_BaudRate = 115200;
USART_InitStruct.USART_WordLength = USART_WordLength_8b;
USART_InitStruct.USART_StopBits = USART_StopBits_1;
USART_InitStruct.USART_Parity = USART_Parity_No;
USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStruct.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
USART_Init(USART1, &USART_InitStruct);
// 配置USART1中断
NVIC_InitStruct.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 0;
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStruct);
// 使能USART1接收中断
USART_ITConfig(USART1, USART_IT_RXNE, ENABLE);
// 使能USART1
USART_Cmd(USART1, ENABLE);
}
关键配置说明:
| 参数 | 值 | 说明 |
|---|---|---|
| 波特率 | 115200 | 常用通信速率,可根据需要调整 |
| 数据位 | 8位 | 标准配置 |
| 停止位 | 1位 | 标准配置 |
| 校验位 | 无 | 简单通信可不使用校验 |
| 流控 | 无 | 大多数情况下不需要硬件流控 |
在嵌入式开发中,printf是最常用的调试输出函数。通过重定向,我们可以让printf的输出直接发送到串口,极大方便调试。
c复制#include <stdio.h>
// 重定向fputc函数
int fputc(int ch, FILE *f)
{
// 发送一个字节到USART1
USART_SendData(USART1, (uint8_t)ch);
// 等待发送完成
while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
return ch;
}
// 重定向fgetc函数
int fgetc(FILE *f)
{
// 等待接收数据
while(USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET);
return (int)USART_ReceiveData(USART1);
}
使用注意事项:
注意:printf函数会占用较多资源,在实时性要求高的场景慎用。可以考虑使用简化版的字符串输出函数替代。
在实际应用中,我们通常需要处理不定长的串口数据。使用中断接收配合环形缓冲区是常见的解决方案。
首先定义环形缓冲区结构:
c复制#define BUF_SIZE 256
typedef struct {
uint8_t buffer[BUF_SIZE];
uint16_t head;
uint16_t tail;
} RingBuffer;
RingBuffer rx_buffer = {0};
然后实现中断服务函数:
c复制void USART1_IRQHandler(void)
{
if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET)
{
// 读取接收到的数据
uint8_t data = USART_ReceiveData(USART1);
// 将数据存入环形缓冲区
uint16_t next = (rx_buffer.head + 1) % BUF_SIZE;
if(next != rx_buffer.tail) // 缓冲区未满
{
rx_buffer.buffer[rx_buffer.head] = data;
rx_buffer.head = next;
}
// 清除中断标志
USART_ClearITPendingBit(USART1, USART_IT_RXNE);
}
}
缓冲区操作函数示例:
c复制// 从缓冲区读取一个字节
uint8_t RingBuffer_ReadByte(void)
{
if(rx_buffer.head == rx_buffer.tail)
return 0; // 缓冲区为空
uint8_t data = rx_buffer.buffer[rx_buffer.tail];
rx_buffer.tail = (rx_buffer.tail + 1) % BUF_SIZE;
return data;
}
// 检查缓冲区是否有数据
uint16_t RingBuffer_Available(void)
{
return (rx_buffer.head - rx_buffer.tail) % BUF_SIZE;
}
在实际项目中,我们通常需要定义通信协议来解析接收到的数据。下面介绍一种简单的帧格式:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 1字节 | 固定为0xAA |
| 长度 | 1字节 | 数据部分长度 |
| 数据 | N字节 | 有效载荷 |
| 校验 | 1字节 | 校验和(所有字节累加和) |
协议解析状态机实现:
c复制typedef enum {
STATE_IDLE,
STATE_HEADER,
STATE_LENGTH,
STATE_DATA,
STATE_CHECKSUM
} ParserState;
ParserState state = STATE_IDLE;
uint8_t frame_length = 0;
uint8_t frame_data[256];
uint8_t data_index = 0;
uint8_t checksum = 0;
void Protocol_Parse(uint8_t data)
{
switch(state)
{
case STATE_IDLE:
if(data == 0xAA)
{
state = STATE_HEADER;
checksum = data;
}
break;
case STATE_HEADER:
frame_length = data;
checksum += data;
data_index = 0;
state = STATE_LENGTH;
break;
case STATE_LENGTH:
if(data_index < frame_length)
{
frame_data[data_index++] = data;
checksum += data;
}
else
{
// 校验和检查
if(checksum == data)
{
// 完整帧接收完成,处理数据
Process_Frame(frame_data, frame_length);
}
state = STATE_IDLE;
}
break;
default:
state = STATE_IDLE;
break;
}
}
在实际开发中,有几个常见的优化点和调试技巧值得注意:
DMA发送示例代码:
c复制void USART1_Send_DMA(uint8_t *data, uint16_t length)
{
// 等待上一次DMA传输完成
while(DMA_GetFlagStatus(DMA1_FLAG_TC4) == RESET);
DMA_Cmd(DMA1_Channel4, DISABLE);
DMA1_Channel4->CNDTR = length;
DMA1_Channel4->CMAR = (uint32_t)data;
DMA_Cmd(DMA1_Channel4, ENABLE);
// 使能USART1的DMA发送
USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE);
}
常见问题排查表:
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 无输出 | 串口线接反 | 检查TX/RX交叉连接 |
| 乱码 | 波特率不匹配 | 检查双方波特率设置 |
| 数据丢失 | 缓冲区溢出 | 增大缓冲区或提高处理速度 |
| 接收不完整 | 中断优先级低 | 调整中断优先级 |
| 程序卡死 | 死循环等待 | 添加超时机制 |
一个好的工程应该结构清晰,便于维护和扩展。以下是推荐的工程目录结构:
code复制/Project
/CMSIS # STM32固件库核心文件
/Drivers
/STM32F10x_StdPeriph_Driver # 标准外设驱动
/Inc
usart.h # 串口模块头文件
ring_buffer.h # 环形缓冲区定义
protocol.h # 通信协议定义
/Src
main.c # 主程序
usart.c # 串口实现
ring_buffer.c # 缓冲区实现
protocol.c # 协议实现
/Utilities # 实用工具
在实现上,建议采用模块化编程,每个功能模块都有对应的.h和.c文件,通过清晰的接口进行交互。这样不仅便于调试,也方便后续的功能扩展。
以一个简单的LED控制为例,演示如何通过串口接收命令控制开发板上的LED。我们定义如下协议格式:
命令处理实现:
c复制void Process_LED_Command(uint8_t cmd)
{
switch(cmd)
{
case 0x01:
GPIO_SetBits(GPIOC, GPIO_Pin_13); // 开LED
printf("LED ON\r\n");
break;
case 0x00:
GPIO_ResetBits(GPIOC, GPIO_Pin_13); // 关LED
printf("LED OFF\r\n");
break;
default:
printf("Unknown command: 0x%02X\r\n", cmd);
break;
}
}
协议解析增强版:
c复制void Protocol_Parse_Enhanced(uint8_t data)
{
static uint8_t state = 0;
static uint8_t cmd = 0;
static uint8_t checksum = 0;
switch(state)
{
case 0: // 等待帧头
if(data == 0x55)
{
checksum = data;
state = 1;
}
break;
case 1: // 读取命令
cmd = data;
checksum ^= data;
state = 2;
break;
case 2: // 校验
if(data == checksum)
{
Process_LED_Command(cmd);
}
state = 0;
break;
}
}
在实际项目中,这种模块化的设计可以轻松扩展支持更多命令和功能,而不会使代码变得混乱。通过printf输出调试信息,配合串口助手,可以很方便地进行功能验证和问题排查。