第4章 UART串口通信:从寄存器到printf重定向
UART串口,说白了就是嵌入式开发者的「眼睛」和「嘴巴」。你想想看,没有显示屏的时候,调试信息怎么打印?全靠它。我在做F1C200s项目时,第一件事就是把串口调通——不然连芯片有没有跑起来都不知道,那多尴尬。
这一章,我会带你从寄存器层面彻底搞懂UART。不是那种「调个库就完事」的玩法,而是亲手操作每一个关键寄存器。相信我,搞懂了这些,以后换任何芯片的UART,你都能快速上手。
4.1 UART模块寄存器详解
F1C200s的UART模块,其实继承自全志家经典的NS16550A架构。如果你玩过PC上的串口,应该对这个不陌生。它一共有三个UART控制器,我们最常用的是UART0,因为它的引脚默认就接在了调试口上。
嗯,这里要注意:UART0的引脚是PF2(TX)和PF4(RX)。别搞错了,我刚开始就吃过这个亏,焊好板子发现怎么都打印不出东西,查了半天发现是引脚配置错了。
关键寄存器就那么几个,我列个表给你看:
| 寄存器 | 偏移地址 | 作用 |
|---|---|---|
| RBR/THR | 0x00 | 读数据/写数据(同一地址) |
| IER | 0x04 | 中断使能控制 |
| IIR/FCR | 0x08 | 中断标识/FIFO控制 |
| LCR | 0x0C | 线路控制(数据格式) |
| MCR | 0x10 | 调制解调器控制 |
| LSR | 0x14 | 线路状态(关键!) |
| DLL/DLH | 0x00/0x04 | 波特率分频系数(需设置DLAB位) |
这里面,我个人觉得最重要的就是LSR寄存器。它告诉你数据有没有收到、发送缓冲区空不空、有没有出错。说白了,串口通信的「握手」全靠它。
核心寄存器:LSR(线路状态寄存器)
bit 0: DR(Data Ready)—— 收到数据了没?1表示有数据可读
bit 5: THRE(Transmitter Holding Register Empty)—— 发送缓冲区空了吗?1表示可以发下一个字节
bit 6: TEMT(Transmitter Empty)—— 发送移位寄存器也空了?1表示完全发送完毕
4.2 波特率计算与配置
波特率,就是每秒传输多少位。常用的有9600、115200。F1C200s的UART时钟源是24MHz(APB总线时钟),我们需要通过分频来得到目标波特率。
计算公式很简单:
分频系数 = UART时钟频率 / (16 × 目标波特率)
举个例子,目标波特率115200:
分频系数 = 24000000 / (16 × 115200) = 13.02
取整后是13。那实际波特率就是:
实际波特率 = 24000000 / (16 × 13) = 115384
误差只有0.16%,完全没问题。我曾经遇到过用921600波特率的情况,分频系数算出来是1.63,取整后误差就大了,结果通信老是丢数据。所以啊,波特率越高,误差容忍度越低,这个要心里有数。
配置步骤是这样的:
- 设置LCR寄存器的bit 7(DLAB位)为1,允许访问分频寄存器
- 将分频系数的低8位写入DLL(0x00),高8位写入DLH(0x04)
- 清除DLAB位(写回0),恢复RBR/THR的正常访问
我的小技巧:配置波特率时,最好先读一下LCR的当前值,只修改DLAB位,其他位保持不变。这样不会意外改变数据格式设置。
4.3 发送与接收字符函数实现
好了,寄存器搞清楚了,波特率也配好了,接下来就是真刀真枪地写代码了。
发送一个字符,流程其实很简单:
- 检查LSR的bit 5(THRE),确保发送缓冲区为空
- 把要发送的字节写入THR寄存器
接收一个字符,反过来:
- 检查LSR的bit 0(DR),确保有数据可读
- 从RBR寄存器读取数据
代码实现如下:
// UART0基地址
#define UART0_BASE 0x01C25000
// 寄存器偏移
#define UART_RBR (UART0_BASE + 0x00) // 接收缓冲
#define UART_THR (UART0_BASE + 0x00) // 发送保持
#define UART_LSR (UART0_BASE + 0x14) // 线路状态
// 发送一个字符
void uart_putchar(char c)
{
// 等待发送缓冲区空
while (!(*(volatile unsigned int *)UART_LSR & (1 << 5)));
// 写入数据
*(volatile unsigned int *)UART_THR = c;
}
// 接收一个字符
char uart_getchar(void)
{
// 等待数据到达
while (!(*(volatile unsigned int *)UART_LSR & 0x01));
// 读取数据
return *(volatile unsigned int *)UART_RBR;
}
这里有个细节:RBR和THR是同一个地址。读的时候是RBR,写的时候是THR。硬件会自动区分,你不用担心。
注意:我在调试时发现,有些芯片的UART FIFO默认是开启的。如果你不关掉FIFO,发送和接收的行为会有点不一样。建议在初始化时,通过FCR寄存器(0x08)把FIFO关掉,等搞明白了再开。
4.4 printf重定向
有了uart_putchar,我们就可以做一件很酷的事——把printf重定向到串口。这样,你就可以用熟悉的printf来打印调试信息了。
在ARM裸机环境下,printf最终会调用一个底层函数来输出字符。这个函数通常是_fputc或者_write(取决于你用的C库)。我们只需要实现这个函数,把字符通过串口发出去就行。
以GCC工具链为例,重定向_write函数:
// 重定向_write,让printf输出到UART
int _write(int file, char *ptr, int len)
{
int i;
for (i = 0; i < len; i++)
{
uart_putchar(ptr[i]);
}
return len;
}
就这么简单。之后你在代码里写printf("Hello, F1C200s!\n"),它就会从串口输出了。
不过要注意,printf本身很「重」。它内部会处理格式化字符串、浮点数转换等,代码体积不小。如果你的Flash空间紧张,可以考虑用轻量级的实现,比如xprintf或者自己写一个简单的格式化输出函数。
我的建议:调试阶段放心用printf,产品阶段再考虑替换。毕竟开发效率也很重要,对吧?
4.5 完整初始化流程
最后,我把UART0的完整初始化代码贴出来。你把它放在系统启动的早期阶段调用,之后就可以愉快地打印了。
void uart_init(void)
{
// 1. 使能UART0时钟(假设GPIO时钟已配置)
// 具体时钟使能寄存器请参考数据手册
// 2. 配置GPIO复用为UART功能
// PF2 -> TX, PF4 -> RX
// 设置GPIO复用寄存器
// 3. 配置UART参数:115200, 8N1
// 设置LCR:8位数据,1位停止位,无校验
*(volatile unsigned int *)(UART0_BASE + 0x0C) = 0x03;
// 4. 设置波特率115200
// 先设置DLAB位
*(volatile unsigned int *)(UART0_BASE + 0x0C) |= (1 << 7);
// 写入分频系数13
*(volatile unsigned int *)(UART0_BASE + 0x00) = 13; // DLL
*(volatile unsigned int *)(UART0_BASE + 0x04) = 0; // DLH
// 清除DLAB位
*(volatile unsigned int *)(UART0_BASE + 0x0C) &= ~(1 << 7);
// 5. 关闭FIFO(可选)
*(volatile unsigned int *)(UART0_BASE + 0x08) = 0x00;
}
嗯,这里要提醒你一句:GPIO的复用配置别忘了。很多新手调不通串口,不是因为UART本身配错了,而是GPIO没切到UART功能上。F1C200s的PF2和PF4,需要把对应的复用寄存器设置为UART功能。
好了,这一章的内容就到这里。串口通信是嵌入式开发的「基本功」,搞懂了它,后面的I2C、SPI其实都是类似的套路——配置寄存器、检查状态、读写数据。下一章,我们会继续深入,看看怎么用中断方式来驱动UART,让CPU不再傻等。
公众号:蓝海资料掘金营,微信deep3321