一、USART基础概念与区别
1. 什么是usart?
- 全称:Universal Synchronous/Asynchronous Receiver/Transmitter,通用同步/异步接发器。
- 定义:它是微控制器(MCU)内部的一个外设模块,用于串行通行。
- 关键点:它既支持同步通信(带时钟线),也支持异步通信(不带时钟线)。
2. USART 与 UART 的区别
| 特性 | UART | USART |
|---|---|---|
| 同步方式 | 仅支持异步 | 支持同步和异步 |
| 时钟线 | 无(依靠波特率约定) | 同步模式下需要时钟线 |
| 应用场景 | 调试打印、GPS模块、蓝牙模块 | 驱动SPI设备、SmartCard、高可靠性通信 |
| 包含关系 | USART 包含了 UART的功能 | USART是UART的超集 |
二、 物理层
1. 引脚连接
- TX (Transmit) :发送端
- RX(Receive):接收端
- GND:共地(必须连接,否则电平无参考,无法通信)
- 连接法则:交叉连接(TX连RX,RX连TX)
2. 电平标准
MCU出来的TTL电平不能直接接电脑的USB,也不能直接远距离传输,需要转换。
-
TTL电平:
- 逻辑1:VCC(3.3V或5V)
- 逻辑0:GND(0V)
- 特点:抗干扰差,距离短,MCU直接使用。
-
RS-232电平:
- 逻辑1:-3V~-15V(负电压)
- 逻辑0:+3V~+15V(正电压)、
- 特点:负逻辑,抗干扰强于TTL,用于工业设备,需使用 MAX232芯片进行转换。
-
RS-485(虽然物理层不同,常与UART协议配合):
- 差分信号(A-B电压差)
- 特点:抗干扰极强,距离可达1200米,半双工
三、协议层(数据帧格式)
UART通信不需要时钟线,依靠约定的波特率和帧格式来解析数据。
数据帧组成:
空闲状态下通常为高电平。
-
起始位:1位,拉低电平,标志传输开始。
-
数据位:5~9位(通常是8位)。低位在前。
-
校验位:可选。
- 奇校验:保证数据位+校验位中的“1“的个数为奇数。
- 偶校验:保证”1“的个数为偶数。
- 无校验:最常用。
-
停止位:1位、1.5位或2位。拉高电平,标志结束。
典型配置:115200,8,N,1(波特率115200,8位数据位,无校验,1停止位)。
四、嵌入式软件设计(核心)
在嵌入式开发中,如何高效接收数据很重要。
1. 三种接收方式
1. 轮询:
- 原理:while(!Flag);Read();死等数据
- 缺点:浪费CPU,容易丢数据,工程中几乎不用。
2. 中断:
- 原理:收到一个字节触发一次中断
- 缺点:如果波特率很高(如921600),中断频率太高,频繁压栈出栈会把CPU累死。
3. DMA + 空闲中断:
- 原理:硬件自动把数据搬到内存,只有在一帧数据发完(总线空闲)时,才触发一次中断通知CPU处理。
- 优点:极大释放CPU资源。
五、代码实现
轮询代码:
#include "usart1.h"
#define USART1_GPIO_PORT GPIOA
#define USART1_TX_GPIO_PIN GPIO_Pin_9
#define USART1_RX_GPIO_PIN GPIO_Pin_10
void usart1_init(uint32_t bound) {
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
// PA9 -> TX (复用推挽输出)
GPIO_InitStructure.GPIO_Pin = USART1_TX_GPIO_PIN;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_Init(USART1_GPIO_PORT, &GPIO_InitStructure);
// PA10 -> RX(浮空输入)
GPIO_InitStructure.GPIO_Pin = USART1_RX_GPIO_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(USART1_GPIO_PORT, &GPIO_InitStructure);
// USART配置
USART_InitTypeDef USART_InitStructure;
USART_InitStructure.USART_BaudRate = bound; // 波特率
USART_InitStructure.USART_WordLength = USART_WordLength_8b; // 8位数据
USART_InitStructure.USART_StopBits = USART_StopBits_1; // 1位停止位
USART_InitStructure.USART_Parity = USART_Parity_No; // 无校验
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; // 无流控
USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
USART_Init(USART1, &USART_InitStructure);
// 是能串口
USART_Cmd(USART1, ENABLE);
}
// =======================================
// 接口函数实现(轮询模式)
// =======================================
/**
* @brief 发送一个字节
* @param byte:要发送的数据
* @note 重点:这里要检查 TXE (Transmit Data Register Empty) 标志位
* TXE置位表示数据寄存器空,可以写入下一个数据,但上一帧可能还在移位寄存器发送中。
这样效率比检查TC (Transmission Complete)更高。
*/
void usart1_send_byte(uint8_t byte) {
// 发送数据
USART_SendData(USART1, byte);
// 等待发送数据寄存器为空(TXE = 1)
// 如果不等待直接发下一个,会直接覆盖当前数据
while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
}
/**
* @brief 发送字符串
* @param str:字符串指针
* @note 利用循环调用单字节发送函数,直到遇到字符串结束符
*/
void usart1_send_string(char* str) {
while (*str != '\0') {
usart1_send_byte(*str);
str++;
}
}
/**
* @brief 发送指定长度的数组(用于发送Hex数据/通信协议包)
* @param arr:数组指针
* @param len:数据长度
*/
void usart1_send_array(uint8_t *arr, uint16_t len) {
uint16_t i;
for (i = 0; i < len; i++) {
usart1_send_byte(arr[i]);
}
}
/**
* @brief 接收一个字节(死等模式)
* @return 接收到的字节
* @note 缺点:如果没有数据发送过来,程序就会卡死在这里,blocking。
*/
uint8_t usart1_receive_byte(void) {
// 等待接收数据寄存器非空 (RXNE = 1)
while (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET);
// 读取数据 (读取操作会自动清除 RXNE 标志)
return (uint8_t)USART_ReceiveData(USART1);
}
/**
* @brief 接收一个字节
* @param data:接收数据存放地址
* @param timeout:超时计数值(非标准时间,取决于CPU主频)
* @return 0:成功,1:超时失败
* @note 在实际工程中,不能让程序永远卡死在while死循环中,必须要有超时机制推出
*/
uint8_t usart1_receive_byte_with_timeout(uint8_t *data, uint32_t timeout) {
// 轮询标志位
while (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET) {
timeout--;
if (timeout == 0) {
return 1;
}
}
*data = (uint8_t)USART_ReceiveData(USART1);
return 0;
}
// 重定向fputc函数,让printf输出到串口
// 注意:需要在keil的Target设置中勾选 “Use MicroLIB”
int fputc(int ch, FILE *f) {
// 发送一个字节
USART_SendData(USART1, (uint8_t) ch);
// 等待发送完成(TC = Transmission Complete)
while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET);
return ch;
}
#include "stm32f10x.h"
#include "usart1.h"
int main(void)
{
systick_init();
bsp_led_init();
uint8_t rxData;
usart1_init(115200);
printf("STM32 USART1 Polling Test Start...\r\n");
usart1_send_string("Please send a byte to me.\r\n");
while(1) {
if (usart1_receive_byte_with_timeout(&rxData, 100000) == 0) {
printf("Recv: %c (Hex: 0x%02X)\r\n", rxData, rxData);
} else {
}
}
}
我这里简单进行一个回显打印,通过串口软件发送数据后,重新发送回去,这里就需要不停的轮询,来判断有没有数据过来,非常简单,但是非常不好用。
[14:30:18.430]发→◇A□
[14:30:18.434]收←◆Recv: A (Hex: 0x41)
中断代码
核心思路
-
接收(RX):使用中断模式
- 开启RXNE中断,读取数据寄存器非空。
- 数据到达时,触发中断,CPU跳入 USART2_IRQHandler。
- 在中断里把数据存入一个环形缓冲区,并判断是否接收到了结束符(如\n)、
-
发送(TX):使用阻塞方式(或重定向printf)。
- 发送通常是主动行为,且数据量不大时,轮询等待发送完成(TC或者TXE标志)最简单稳定。
#include "usart2.h"
#include "ring_buffer.h"
#define BUF_SIZE 16
uint8_t usart2_buffer[BUF_SIZE];
ring_buffer_t usart2_rb;
void usart2_init(uint32_t bound) {
rb_init(&usart2_rb, usart2_buffer, BUF_SIZE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2 , ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitTypeDef GPIO_InitStructure;
// TX
GPIO_InitStructure.GPIO_Pin = USART2_TX_GPIO_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(USART2_GPIO_PORT, &GPIO_InitStructure);
// RX
GPIO_InitStructure.GPIO_Pin = USART2_RX_GPIO_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(USART2_GPIO_PORT, &GPIO_InitStructure);
// 初始化usart2
USART_InitTypeDef USART2_InitStructure;
USART2_InitStructure.USART_BaudRate = bound;
USART2_InitStructure.USART_WordLength = USART_WordLength_8b;
USART2_InitStructure.USART_StopBits = USART_StopBits_1;
USART2_InitStructure.USART_Parity = USART_Parity_No;
USART2_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART2_InitStructure.USART_Mode = USART_Mode_Rx|USART_Mode_Tx;
USART_Init(USART2, &USART2_InitStructure);
USART_ITConfig(USART2, USART_IT_RXNE, ENABLE);
NVIC_InitTypeDef NVIC_InitStructure;
// 配置USASR1为中断源
NVIC_InitStructure.NVIC_IRQChannel = USART2_IRQn;
// 抢占优先级
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
// 子优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
// 使能中断
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
USART_Cmd(USART2, ENABLE);
}
void usart2_send_byte(uint8_t byte) {
USART_SendData(USART2, byte);
while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET);
}
void usart2_send_string(char *str) {
while (*str != '\0') {
usart2_send_byte(*str);
str++;
}
}
uint8_t usart2_read_byte_from_buffer(uint8_t *pData) {
if (rb_get(&usart2_rb, pData) == RB_SUCCESS) {
return 1;
}
return 0;
}
// -------------------------------------------------------
// 中断服务函数(重点)
// -------------------------------------------------------
void USART2_IRQHandler(void) {
// 1. 检查是否接收中断(RXNE)
if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) {
// 读取数据(读取数据会自动清楚RXNE标志)
uint8_t data = (uint8_t)USART_ReceiveData(USART2);
// 2. 判断缓冲区是否已经满了
if (!rb_is_full(&usart2_rb)) {
rb_put(&usart2_rb, data);
} else {
// 缓冲区满了,丢弃数据
// 可以在这里做一个全局计数器 g_rx_overflow_count++ 用于调试
}
}
// 关键
// 如果CPU处理过慢,导致数据覆盖,ORE会置位,如果不清除,中断会一直触发或卡死
if (USART_GetFlagStatus(USART2, USART_FLAG_ORE) != RESET) {
// 清除ORE标志,通常需要:读SR寄存器 -》读DR寄存器
volatile uint8_t temp;
temp = USART2->SR;
temp = USART2->DR;
(void)temp;
}
}
第五部分:嵌入式面试关键点
Q1: 串口通信是全双工还是半双工?
- 答:UART/USART通常是全双工的,因为有独立的TX和RX线,可以同时收发。
- 追问:RS-485呢?
- 答:RS-485通常是半双工的,同一时刻只能发或只能收。
Q2: 波特率 115200 是什么意思?传输 1 Byte 需要多久?
- 答:115200 表示每秒传输 115200 个 bit。
- 计算:配置为 8,N,1 时,传输1字节实际包含:1起始 + 8数据 + 0校验 + 1停止 = 10 bits。时间 =
10÷115200≈86.8μs10÷115200≈86.8μs。也就是说,1秒钟理论上最多传输115200÷10=11520115200÷10=11520个字节(约11KB/s)。
Q3: 为什么双方波特率必须一致?允许误差是多少?
- 答:因为是异步通信,接收端依靠内部时钟对信号进行采样。如果没有时钟线同步,双方必须约定好速率。
- 误差:通常允许误差在 2% ~ 5% 之间。如果累计误差超过半个bit的时间,采样就会错位,导致乱码。
Q4: 接收端如何检测信号?(采样原理)
- 答:这是底层原理题。接收器通常使用过采样 (Oversampling) 技术(如16倍频)。检测到下降沿(起始位)后,计数器开始计数。在第 8, 9, 10 个采样点(即bit的中间位置)读取电平,通过多数表决(3次中有2次是高即为高)来确定该位的值,从而消除噪声干扰。
Q5: 常见的串口错误有哪些?
- Framing Error (帧错误) :没有读到预期的停止位(通常因波特率不匹配或干扰)。
- Parity Error (校验错误) :奇偶校验失败。
- Overrun Error (溢出错误) :RX寄存器里的旧数据还没被CPU取走,新数据又来了,覆盖了旧数据。
Q6: 为什么长距离传输用RS485而不用TTL或RS232?
- 答:TTL和RS232是单端信号(信号线对地电压),容易受到共模干扰(地电位波动或电磁辐射)。RS-485采用差分信号(两根线电压之差),干扰同时作用在两根线上,差值不变,因此抗干扰能力极强。
Q7: 编写一个串口接收环形缓冲区的判空和判满逻辑。
#define BUFFER_SIZE 128
typedef struct {
uint8_t buffer[BUFFER_SIZE];
volatile uint16_t head; // 写入位置
volatile uint16_t tail; // 读取位置
} RingBuffer;
// 判空
int IsBufferEmpty(RingBuffer *rb) {
return (rb->head == rb->tail);
}
// 判满 (保留一个字节不用,用于区分空和满)
int IsBufferFull(RingBuffer *rb) {
return ((rb->head + 1) % BUFFER_SIZE == rb->tail);
}
总结建议
- 硬件上:搞清楚 TTL vs RS232 vs RS485 的区别。
- 软件上:重点掌握 DMA + 空闲中断 的流程,以及 环形缓冲区 的实现。
- 调试上:知道如果不通该怎么排查(查共地、查交叉连接、查波特率、查晶振)。