4. 串口通信基础(UART)
UART,说白了就是串口通信的物理层协议。我刚开始做嵌入式时,总觉得这东西太简单,不就是两根线收发数据嘛。直到有一次调试Bootloader,串口死活收不到正确数据,折腾了两天才发现是波特率算错了……嗯,从那以后我再也不敢小看UART的基础知识了。
4.1 UART协议帧格式
UART的帧格式其实很固定。你想想看,它不像SPI或I2C有时钟线,全靠收发双方约定好速率来采样。所以帧结构里必须包含同步信息。
一个标准的UART数据帧长这样:
| 字段 | 位数 | 说明 |
|---|---|---|
| 起始位 | 1 bit | 固定为低电平,标志传输开始 |
| 数据位 | 5~9 bits | 常用8位,LSB先发 |
| 校验位 | 0/1 bit | 可选奇校验/偶校验/无校验 |
| 停止位 | 1/1.5/2 bits | 固定为高电平,标志帧结束 |
我个人习惯用8N1——8位数据、无校验、1位停止位。这是最通用的配置,兼容性最好。你在项目中如果遇到通信双方协议不一致,先检查这个帧格式配置。
关键点:UART是异步通信,没有时钟线。收发双方必须用相同的波特率、相同的帧格式,否则数据就是乱码。
4.2 波特率计算
波特率就是每秒传输的符号数。对于UART来说,1个符号就是1个bit。所以波特率 = 比特率。
CMS32的串口模块通常由系统时钟分频得到波特率。计算公式如下:
波特率 = 系统时钟频率 / (16 × (UBR + 1))
其中UBR是波特率寄存器值。反过来算:
UBR = (系统时钟频率 / (16 × 目标波特率)) - 1
举个例子。系统时钟24MHz,目标波特率115200:
UBR = (24000000 / (16 × 115200)) - 1
= (24000000 / 1843200) - 1
≈ 13.02 - 1
= 12.02
取整后UBR = 12。实际波特率 = 24000000 / (16 × 13) ≈ 115384。误差只有0.16%,完全可用。
注意:波特率误差超过2%就容易丢数据。我曾经在项目中用8MHz晶振配9600波特率,算出来误差3.1%,结果串口助手收的数据全是乱码。后来换了11.0592MHz晶振才解决。
常用波特率与UBR值对照表(系统时钟24MHz):
| 目标波特率 | UBR计算值 | UBR取整 | 实际波特率 | 误差 |
|---|---|---|---|---|
| 9600 | 155.25 | 155 | 9615 | 0.16% |
| 19200 | 77.125 | 77 | 19231 | 0.16% |
| 38400 | 38.0625 | 38 | 38462 | 0.16% |
| 57600 | 25.04 | 25 | 57692 | 0.16% |
| 115200 | 12.02 | 12 | 115385 | 0.16% |
4.3 CMS32串口寄存器配置
CMS32的串口模块叫UARTn,n从0开始。配置串口,说白了就是操作几个关键寄存器。
我一般按这个顺序配置:
- 时钟使能:打开UART模块时钟
- 引脚复用:把GPIO配置为UART功能
- 波特率设置:写UBR寄存器
- 帧格式设置:数据位、校验位、停止位
- 中断使能:如果需要中断方式
- 使能UART:启动收发
核心寄存器一览:
| 寄存器 | 功能 |
|---|---|
| UARTn_BRR | 波特率寄存器,写入UBR值 |
| UARTn_CR1 | 控制寄存器1,使能UART、设置数据位 |
| UARTn_CR2 | 控制寄存器2,设置停止位、校验位 |
| UARTn_SR | 状态寄存器,查询收发完成标志 |
| UARTn_DR | 数据寄存器,读写收发数据 |
配置代码示例:
void UART1_Init(void)
{
// 1. 时钟使能
RCC_APB2PeriphClockCmd(RCC_APB2Periph_UART1, ENABLE);
// 2. 引脚复用:PA9 = TX, PA10 = RX
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9 | GPIO_Pin_10;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStruct);
// 3. 波特率设置:115200 @ 24MHz
UART1->BRR = 12;
// 4. 帧格式:8N1
UART1->CR1 = 0x00; // 8位数据
UART1->CR2 = 0x00; // 1位停止位,无校验
// 5. 使能UART
UART1->CR1 |= 0x2000; // UE使能位
UART1->CR1 |= 0x000C; // TE和RE使能
}
小技巧:配置完寄存器后,先发一个0x55(二进制01010101)测试。如果示波器看到方波,说明配置没问题。我每次调串口都这么干,省了不少排查时间。
4.4 阻塞式发送与接收
阻塞式,说白了就是CPU死等。发送时等数据发完,接收时等数据到来。效率不高,但简单可靠。
发送一个字节:
void UART1_SendByte(uint8_t data)
{
// 等待发送缓冲区空
while (!(UART1->SR & 0x0080));
// 写入数据
UART1->DR = data;
}
接收一个字节:
uint8_t UART1_ReceiveByte(void)
{
// 等待接收数据就绪
while (!(UART1->SR & 0x0020));
// 读取数据
return (uint8_t)(UART1->DR & 0xFF);
}
发送字符串:
void UART1_SendString(const char *str)
{
while (*str)
{
UART1_SendByte(*str++);
}
}
注意:阻塞式接收有个大坑——如果对方不发数据,程序就卡死在while循环里。我在Bootloader项目中遇到过,上位机掉线了,下位机一直等接收,整个系统死机。解决方案是加超时机制,或者用中断+超时。
4.5 串口打印重定向(printf)
printf是调试利器。但嵌入式C库的printf默认输出到stdout,不会自动走串口。我们需要重定向。
重定向的核心就是实现fputc函数。C库的printf最终会调用fputc来输出每个字符。
int fputc(int ch, FILE *f)
{
// 阻塞发送一个字符
while (!(UART1->SR & 0x0080));
UART1->DR = (uint8_t)ch;
return ch;
}
然后在工程设置里勾选"Use MicroLIB"。MicroLIB是ARM提供的精简C库,体积小,支持printf重定向。
配置完成后,就可以愉快地用了:
printf("系统初始化完成,当前温度:%d°C\n", temperature);
printf("Bootloader版本:%s\n", VERSION_STRING);
经验之谈:printf默认是阻塞的。如果串口速率低(比如9600),打印大量数据会拖慢系统。我一般只在调试阶段用printf,正式发布前会加一个宏开关,把printf全部关掉。
4.6 本章知识结构
下面这张图把UART的核心知识点串起来了。从帧格式到寄存器配置,再到收发实现和printf重定向,一条线走通。
这张图把UART的五个核心模块串起来了。从帧格式到波特率计算,再到寄存器配置和收发实现,最后用printf重定向收尾。每个模块之间都有依赖关系——帧格式决定了寄存器怎么配,波特率算错了收发全是乱码,收发函数又是printf的基础。