串口通信与调试:USART协议详解、串口配置与数据收发、使用串口打印调试信息、与上位机通信协议设计
好,咱们进入第三章。这一章聊的是串口,也就是USART。
说实话,在MRI患者定位系统里,串口可能是你最常用的调试手段。没有之一。为什么?因为LCD屏还没初始化好,JTAG/SWD可能被占用了,但串口——只要两根线,就能让你看到系统在干什么。
我个人习惯,拿到一块新板子,第一件事就是点亮LED,第二件事就是调通串口。串口通了,心就定了。
USART协议,说白了就是“约定”
USART全称是Universal Synchronous/Asynchronous Receiver/Transmitter。名字挺长,但咱们平时用的基本都是异步模式,也就是UART。
UART的通信就三根线:TX(发送)、RX(接收)、GND(地)。没有时钟线,那双方怎么对齐节奏?靠的是事先约定好的“波特率”。
波特率,就是每秒传输的比特数。比如115200,就是每秒传115200个bit。注意是bit,不是byte。一个字节通常要加起始位、停止位,可能还有校验位,所以实际传一个字节需要10个或11个bit。
数据格式长这样:
| 起始位 | 数据位(5~9位) | 校验位(可选) | 停止位(1/1.5/2位) |
|---|---|---|---|
| 1位,固定为0 | 通常8位 | 奇/偶/无 | 1位,固定为1 |
我最常用的配置是:115200波特率、8位数据、无校验、1位停止位。简写就是115200-8-N-1。这个组合在绝大多数MCU和上位机软件里都是默认的,省心。
串口配置,其实就几步
以STM32为例,配置一个串口大概分四步:
- 使能时钟——GPIO时钟和USART时钟都要开
- 配置GPIO——TX设为复用推挽输出,RX设为浮空输入或上拉输入
- 配置USART——波特率、数据位、停止位、校验位
- 使能USART——开接收、开发送、开中断(如果需要)
代码长这样:
void USART1_Init(void)
{
// 1. 使能时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
// 2. 配置GPIO
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9; // TX
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStruct);
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10; // RX
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStruct);
// 3. 配置USART
USART_InitTypeDef USART_InitStruct;
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_Tx | USART_Mode_Rx;
USART_Init(USART1, &USART_InitStruct);
// 4. 使能
USART_Cmd(USART1, ENABLE);
}
嗯,这里要注意:配置顺序不能乱。先开时钟,再配GPIO,再配USART,最后使能。我见过有人把使能放在配置前面,结果怎么调都不工作——寄存器还没配好呢,外设就先开了。
数据收发,轮询还是中断?
发送数据很简单,往DR寄存器里写数据就行。但要注意,写之前要等TXE(发送数据寄存器空)标志位。不然上一个字节还没发完,你写进去就把数据覆盖了。
接收数据也一样,读之前要等RXNE(接收数据寄存器非空)标志位。
轮询方式:
void USART_SendByte(uint8_t data)
{
while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
USART_SendData(USART1, data);
}
uint8_t USART_ReceiveByte(void)
{
while (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET);
return USART_ReceiveData(USART1);
}
但轮询有个问题——它会卡住CPU。如果你在主循环里一直等接收,那其他事情都干不了。所以实际项目中,我几乎只用中断方式接收。
中断接收的套路:
void USART1_IRQHandler(void)
{
if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET)
{
uint8_t data = USART_ReceiveData(USART1);
// 把数据放进环形缓冲区
RingBuf_Put(&rxBuf, data);
}
}
主循环里再从环形缓冲区取数据解析。这样接收不阻塞,发送可以继续用轮询——因为发送通常是你主动的,不会卡太久。
用串口打印调试信息
调试信息怎么打?最原始的办法是每个模块自己写一个串口发送函数。但这样太乱了。我建议统一封装一个调试打印模块。
比如这样:
// debug.h
#define DEBUG_LEVEL_INFO 0
#define DEBUG_LEVEL_WARN 1
#define DEBUG_LEVEL_ERROR 2
void Debug_Init(void);
void Debug_Printf(const char *fmt, ...);
void Debug_SetLevel(uint8_t level);
// 使用示例
Debug_Printf("[INFO] 系统初始化完成, 当前温度: %d°C\n", temp);
为什么不用标准库的printf?因为printf默认输出到stdout,而stdout在嵌入式环境里不一定指向串口。你需要重定向fputc函数,或者像我这样自己写一个轻量级的printf。
我自己写的Debug_Printf,内部用了vsnprintf格式化字符串,然后逐字节发送。虽然比标准printf慢一点,但胜在可控——我可以加时间戳、加颜色控制、加过滤等级。
调试信息分级很重要。开发阶段可以全开,但产品发布时,我只留ERROR级别的打印。不然串口会被日志刷爆,影响正常通信。
与上位机通信协议设计
这是本章的重头戏。串口调通了,数据能发了,但怎么让上位机理解你在说什么?
裸发数据是不行的。你发一个0x55,上位机不知道这是温度、位置还是状态。所以需要协议。
我常用的协议格式:
| 帧头 | 长度 | 命令字 | 数据域 | 校验 | 帧尾 |
|---|---|---|---|---|---|
| 2字节 | 1字节 | 1字节 | N字节 | 1字节 | 2字节 |
| 0xAA 0x55 | 数据域长度 | 0x01~0xFF | 实际数据 | 累加和 | 0x0D 0x0A |
帧头用0xAA 0x55,是为了让接收方快速找到一帧的开始。帧尾用0x0D 0x0A(回车换行),方便用串口助手直接看。
校验我用最简单的累加和——从长度字节开始,到数据域最后一个字节,全部加起来取低8位。够用,而且计算快。
举个例子,我要发送“设置目标位置为100mm”这个命令:
命令字: 0x10 (设置位置)
数据域: 0x00 0x64 (100的16位表示, 大端)
长度: 0x02
校验: 0x10 + 0x02 + 0x00 + 0x64 = 0x76
完整帧: AA 55 02 10 00 64 76 0D 0A
接收端解析时,先找帧头,再读长度,然后收够数据,算校验,校验通过就执行命令。不通过就丢弃,等下一帧。
状态机解析的伪代码:
enum {
STATE_IDLE,
STATE_HEADER1,
STATE_HEADER2,
STATE_LENGTH,
STATE_DATA,
STATE_CHECKSUM,
STATE_FOOTER1,
STATE_FOOTER2
} rxState = STATE_IDLE;
void ParseByte(uint8_t byte)
{
switch (rxState) {
case STATE_IDLE:
if (byte == 0xAA) rxState = STATE_HEADER1;
break;
case STATE_HEADER1:
if (byte == 0x55) rxState = STATE_HEADER2;
else rxState = STATE_IDLE;
break;
case STATE_HEADER2:
rxLen = byte;
rxIndex = 0;
rxState = STATE_LENGTH;
break;
case STATE_LENGTH:
rxCmd = byte;
rxState = STATE_DATA;
break;
case STATE_DATA:
rxBuf[rxIndex++] = byte;
if (rxIndex >= rxLen) rxState = STATE_CHECKSUM;
break;
case STATE_CHECKSUM:
// 计算校验并比较
if (CalcChecksum() == byte) rxState = STATE_FOOTER1;
else rxState = STATE_IDLE;
break;
case STATE_FOOTER1:
if (byte == 0x0D) rxState = STATE_FOOTER2;
else rxState = STATE_IDLE;
break;
case STATE_FOOTER2:
if (byte == 0x0A) {
// 一帧完整接收,处理命令
ProcessCommand(rxCmd, rxBuf, rxLen);
}
rxState = STATE_IDLE;
break;
}
}
这个状态机看着长,但逻辑清晰。每个字节只做一件事,不会丢数据,也不会被粘包搞乱。
最后说一句:协议设计没有标准答案。你可以用更复杂的CRC校验,也可以用更短的帧头。但核心原则不变——可解析、可容错、可扩展。我在MRI定位系统里用的协议,从第一版到现在迭代了三次,每次都是因为遇到了新的需求。所以一开始别设计得太死,留点扩展余地。
好,这一章就到这儿。下一章咱们聊定时器和PWM,那是控制电机的基础。