4. 串口通信:UART协议详解、串口收发实现、环形缓冲区设计

串口通信,在嵌入式世界里,可以说是最基础也最常用的通信方式了。我刚开始做显微镜固件那会儿,调试信息全靠它打印出来。你想想看,一个摄像头模组初始化成不成功,电机走到哪一步了,没有串口,你根本不知道芯片内部在干什么。

这一章,我们就来把UART协议彻底讲透。从协议本身,到收发实现,再到环形缓冲区这个经典设计。嗯,这些都是实战中绕不开的东西。

4.1 UART协议,说白了就是两根线的事

UART全称是Universal Asynchronous Receiver/Transmitter,通用异步收发器。异步,意味着没有时钟线。收发双方靠约定的波特率来同步。

物理层上,就两根线:TX(发送)和RX(接收)。交叉连接,你发我收,你收我发。地线当然也要共地,这个别忘了我吃过亏。

关键参数:

  • 波特率(Baud Rate):每秒传输的符号数。常见的有9600、115200。显微镜应用中,我一般用115200,速度够快,稳定性也还行。
  • 数据位:通常是8位。也有7位、9位的,但8位最通用。
  • 停止位:1位或2位。我习惯用1位,省时间。
  • 校验位:奇校验、偶校验或无校验。大多数场景下,无校验就够了。

数据帧的格式是这样的:

起始位(1位) + 数据位(5~9位) + 校验位(可选) + 停止位(1~2位)

举个例子,发送一个字节0x55(二进制01010101),在115200波特率、8N1(8数据位、无校验、1停止位)格式下,波形就是:

空闲(高) → 起始位(低) → 0 → 1 → 0 → 1 → 0 → 1 → 0 → 1 → 停止位(高)

注意,数据位是LSB先发。这一点很多新手会搞反。我在项目中就遇到过,上位机收到的数据全是反的,排查了半天才发现是字节序搞错了。

4.2 串口收发实现,从轮询到中断

实现串口收发,主要有两种方式:轮询和中断。

4.2.1 轮询方式

轮询,就是CPU不断去检查串口状态寄存器。有数据就收,能发送就发。代码很简单,但效率低。

// 伪代码示例:轮询发送一个字节
void uart_send_byte(uint8_t data) {
    // 等待发送缓冲区空
    while (!(UART->SR & UART_SR_TXE));
    // 写入数据
    UART->DR = data;
}

// 伪代码示例:轮询接收一个字节
uint8_t uart_recv_byte(void) {
    // 等待接收数据就绪
    while (!(UART->SR & UART_SR_RXNE));
    // 读取数据
    return (uint8_t)(UART->DR);
}

这种写法,在简单的调试场景下没问题。但如果你在显微镜的主循环里这么干,CPU就被占死了。电机控制、图像采集这些实时任务就别想跑了。

4.2.2 中断方式

中断方式就好多了。有数据来了,硬件自动触发中断,CPU在中断服务函数里处理。发送也一样,发送完成触发中断,再发下一个字节。

// 中断接收服务函数(伪代码)
void UART_IRQHandler(void) {
    if (UART->SR & UART_SR_RXNE) {
        uint8_t data = (uint8_t)(UART->DR);
        // 把data放到缓冲区里
        ring_buffer_write(&rx_buffer, data);
    }
}

我个人习惯,所有串口通信都用中断方式。轮询只用在初始化阶段的简单自检。你想想看,一个显微镜系统,要同时处理电机、传感器、图像数据,CPU的时间片很宝贵,不能浪费在傻等上。

4.3 环形缓冲区,解决数据堆积的利器

中断方式虽然好,但有个问题:中断来得太快怎么办?比如上位机以1MHz的速率发数据,而你的中断服务函数处理不过来,数据就会丢失。

环形缓冲区就是用来解决这个问题的。它本质上是一个固定大小的数组,配合读指针和写指针,实现先进先出的数据缓存。

环形缓冲区的核心结构:

  • 缓冲区数组:一块连续的内存空间
  • 写指针(head):指向下一个要写入的位置
  • 读指针(tail):指向下一个要读取的位置
  • 满/空判断:通过指针关系判断
// 环形缓冲区结构体定义
typedef struct {
    uint8_t buffer[RING_BUFFER_SIZE];
    volatile uint16_t head;  // 写指针
    volatile uint16_t tail;  // 读指针
} ring_buffer_t;

// 初始化
void ring_buffer_init(ring_buffer_t *rb) {
    rb->head = 0;
    rb->tail = 0;
}

// 写入一个字节
int ring_buffer_write(ring_buffer_t *rb, uint8_t data) {
    uint16_t next_head = (rb->head + 1) % RING_BUFFER_SIZE;
    if (next_head == rb->tail) {
        // 缓冲区满了
        return -1;
    }
    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return 0;
}

// 读取一个字节
int ring_buffer_read(ring_buffer_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        // 缓冲区空了
        return -1;
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % RING_BUFFER_SIZE;
    return 0;
}

小技巧: 环形缓冲区的大小,我一般设为2的幂次方,比如64、128、256。这样取模运算可以用位运算代替,效率更高。

next_head = (head + 1) & (SIZE - 1);

注意: 环形缓冲区有一个经典陷阱:当head追上tail时,表示缓冲区满了。但此时如果head == tail,又会被误判为空。所以通常的做法是,牺牲一个元素空间,用(head + 1) % SIZE == tail来判断满。或者增加一个count变量记录当前数据个数。

我曾经在一个项目中,就是因为没处理好这个边界条件,导致数据覆盖,显微镜的Z轴位置信息丢失,对焦对了好久都对不上。后来查出来是缓冲区满了还在写,把旧数据冲掉了。

4.4 实战中的串口设计要点

好了,理论讲完了,我们来看看在显微镜固件中,串口到底怎么用。

场景 波特率 缓冲区大小 说明
调试日志 115200 256字节 打印调试信息,不需要太大
命令控制 115200 64字节 接收上位机指令,命令通常很短
图像数据传输 921600或更高 4096字节 传输图像数据,缓冲区要大,防止丢帧

我个人习惯,每个串口都配一个独立的环形缓冲区。中断服务函数只管往缓冲区里塞数据,主循环再从缓冲区里取数据解析。这样中断服务函数执行时间极短,不会影响其他中断。

另外,串口初始化时,别忘了配置GPIO的模式。TX要设为推挽输出,RX要设为浮空输入或上拉输入。这个细节,新手很容易忽略。

嗯,这一章的内容就到这里。串口通信看似简单,但用好了,能解决很多实际问题。下一章,我们会讲I2C通信,显微镜里很多传感器都用这个协议。到时候再聊。