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通信,显微镜里很多传感器都用这个协议。到时候再聊。