第三章:UART通信与调试
UART,说白了就是串口通信。做嵌入式开发这十几年,我调试过的板子少说也有上百块,可以说90%的调试工作都离不开UART。你想想看,一个MCU刚焊好,能不能跑起来?打印一句"Hello World"就是最直接的验证方式。
3.1 UART协议详解
UART的全称是Universal Asynchronous Receiver/Transmitter,通用异步收发器。注意"异步"这两个字——收发双方没有独立的时钟线,全靠约定好的参数来同步。
波特率
波特率就是每秒传输的符号数。常见的值有9600、115200、921600等。我个人习惯用115200,速度够快,大部分MCU也都能稳定支持。
这里有个坑:波特率不是越高越好。我曾经在一个项目里把波特率设到2Mbps,结果在长距离传输时数据全是乱码。为什么?因为线缆的寄生电容会把高频信号给"吃掉"。所以,实际项目中要根据线长和电磁环境来选。
| 波特率 | 适用场景 | 最大线长(参考) |
|---|---|---|
| 9600 | 调试输出、低速率传感器 | 15米 |
| 115200 | 通用调试、模块通信 | 3米 |
| 921600 | 高速数据传输 | 0.5米 |
数据位与停止位
数据位通常是8位,也有5、6、7位的选项。现在基本都用8位,因为一个字节正好8位,处理起来最方便。
停止位有1位、1.5位和2位。我建议用1位停止位就够了。除非你的时钟精度很差,才考虑用2位停止位来增加容错。嗯,我在用内部RC振荡器做UART时遇到过这种情况,时钟漂移太大,最后不得不改成2位停止位才稳定下来。
校验位
校验位分奇校验和偶校验。说实话,我现在很少用校验位。为什么?因为UART本身是面向连接的短距离通信,出错概率很低。真要保证数据可靠,不如在应用层加CRC校验。
UART帧格式总结:
起始位(1位) + 数据位(5~8位) + 校验位(0/1位) + 停止位(1/1.5/2位)
最常用配置:8N1(8数据位、无校验、1停止位)
3.2 printf重定向实现
printf重定向,说白了就是把标准库的printf函数输出到你的UART上。这样你就可以用熟悉的printf来打印调试信息了。
不同的编译器,实现方式略有不同。我以最常用的ARM GCC和Keil为例。
ARM GCC环境
// 重写 _write 系统调用
int _write(int file, char *ptr, int len)
{
for (int i = 0; i < len; i++)
{
// 等待发送完成
while (!(USART1->SR & USART_SR_TXE));
USART1->DR = ptr[i];
}
return len;
}
Keil MDK环境
// 重写 fputc 函数
int fputc(int ch, FILE *f)
{
// 等待发送完成
while (!(USART1->SR & USART_SR_TXE));
USART1->DR = ch;
return ch;
}
小技巧:重定向后记得在工程设置中勾选"Use MicroLIB",否则printf会占用大量栈空间。我见过有人没开MicroLIB,结果printf一调用就栈溢出,查了半天才发现。
浮点数打印问题
printf默认不支持浮点数打印,因为浮点库太大了。如果你需要打印浮点数,有两个选择:
- 在编译器选项里开启浮点支持(会增加代码体积)
- 自己写一个简单的浮点转字符串函数
我个人倾向于第二种。比如这样:
void print_float(float val, int decimal_places)
{
int int_part = (int)val;
int frac_part = (int)((val - int_part) * pow(10, decimal_places));
printf("%d.%d", int_part, frac_part);
}
3.3 环形缓冲区设计与应用
环形缓冲区,也叫循环队列。为什么要用它?你想想看,UART接收数据是中断触发的,但数据处理是在主循环里。如果不用缓冲区,中断里收到的数据没来得及处理就被覆盖了。
我刚开始做开发时,就吃过这个亏。一个GPS模块每秒输出10条NMEA语句,我直接在中断里解析,结果解析到一半下一条数据就来了,数据全乱套了。
环形缓冲区原理
说白了就是一个固定大小的数组,加上两个指针:读指针和写指针。写指针追着读指针跑,形成一个环。
typedef struct {
uint8_t buffer[BUFFER_SIZE];
volatile uint16_t head; // 写指针
volatile uint16_t tail; // 读指针
} ring_buffer_t;
注意:head和tail一定要加volatile关键字。因为中断和主循环都会修改它们,不加volatile的话,编译器优化可能会把变量值缓存在寄存器里,导致数据不同步。我曾经因为这个bug排查了整整两天。
核心操作函数
// 写入一个字节
bool ring_buffer_write(ring_buffer_t *rb, uint8_t data)
{
uint16_t next_head = (rb->head + 1) % BUFFER_SIZE;
if (next_head == rb->tail) {
return false; // 缓冲区满
}
rb->buffer[rb->head] = data;
rb->head = next_head;
return true;
}
// 读取一个字节
bool ring_buffer_read(ring_buffer_t *rb, uint8_t *data)
{
if (rb->head == rb->tail) {
return false; // 缓冲区空
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % BUFFER_SIZE;
return true;
}
缓冲区大小的选择
缓冲区大小怎么定?我一般按这个公式估算:
缓冲区大小 ≥ 最大中断间隔时间 × 波特率 / 10
举个例子:如果你的系统最大中断关闭时间是1ms,波特率115200,那么:
缓冲区大小 ≥ 0.001 × 115200 / 10 ≈ 12字节
但实际项目中,我建议至少留2~3倍的余量。比如上面这个场景,我会设成64字节。为什么?因为中断关闭时间可能比你预估的要长,而且还要考虑数据突发的场景。
实际应用示例:
在中断服务函数中调用 ring_buffer_write 存入数据
在主循环中调用 ring_buffer_read 取出数据并处理
这样就把"接收"和"处理"解耦了,互不干扰
避坑指南
我曾经在一个项目中,环形缓冲区总是莫名其妙地丢数据。查了很久才发现,是中断优先级的问题——高优先级中断把UART中断给抢占了,导致数据没来得及写入缓冲区就被覆盖了。
解决方案有两个:
- 把UART中断优先级设高一些
- 在中断里用双缓冲机制,一个缓冲区满了就切换到另一个
嗯,说到双缓冲,其实就是在环形缓冲区的基础上再加一层保护。不过对于大多数场景,环形缓冲区已经够用了。
好了,UART通信与调试这部分就讲到这里。下一章我们聊聊I2C总线,那又是一个有意思的话题。