串口通信与调试: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和上位机软件里都是默认的,省心。

小技巧: 调试阶段波特率别设太高。115200够用,460800也行。但超过1Mbps,线长一点就容易丢数据。我在项目里吃过这个亏,线长了半米,数据就开始乱码。

串口配置,其实就几步

以STM32为例,配置一个串口大概分四步:

  1. 使能时钟——GPIO时钟和USART时钟都要开
  2. 配置GPIO——TX设为复用推挽输出,RX设为浮空输入或上拉输入
  3. 配置USART——波特率、数据位、停止位、校验位
  4. 使能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);
    }
}

主循环里再从环形缓冲区取数据解析。这样接收不阻塞,发送可以继续用轮询——因为发送通常是你主动的,不会卡太久。

避坑指南: 我曾经在中断服务函数里直接调用printf,结果死机了。为什么?printf内部用了互斥锁或者重入问题,在中断里调用很容易出问题。记住:中断里只做最轻量的事,比如存数据到缓冲区。复杂处理放主循环。

用串口打印调试信息

调试信息怎么打?最原始的办法是每个模块自己写一个串口发送函数。但这样太乱了。我建议统一封装一个调试打印模块。

比如这样:

// 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

接收端解析时,先找帧头,再读长度,然后收够数据,算校验,校验通过就执行命令。不通过就丢弃,等下一帧。

经验之谈: 协议设计时,一定要考虑“粘包”和“断包”。串口是一字节一字节来的,可能一帧数据分两次到达。所以接收端要用状态机来解析,不能假设一次收完。我见过有人用简单的if判断,结果数据一多就乱套。

状态机解析的伪代码:

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,那是控制电机的基础。