3. CH32V内核基础
UART串口,搞嵌入式的人天天打交道。调试打印、跟传感器通信、和上位机握手,哪样都离不开它。CH32V203这颗芯片的UART模块,我用了快两年,有些坑确实得跟大家说说。
这一章,咱们就围绕UART的初始化、中断接收、轮询发送、波特率误差和环形缓冲区这几个核心点展开。说白了,就是让你能写出一个稳定、高效的串口驱动。
3.1 UART初始化流程
初始化UART,步骤其实不多,但顺序错了就容易出问题。我个人习惯按这个流程走:
- 使能时钟:先开GPIO时钟,再开UART模块时钟。顺序反了可能会卡住。
- 配置GPIO复用:TX设为推挽输出,RX设为浮空输入或上拉输入。
- 复位UART:调用USART_DeInit,把寄存器清干净。
- 配置参数:波特率、数据位、停止位、校验位。
- 配置中断:使能接收中断(RXNE),如果需要发送完成中断也可以开。
- 使能UART:最后调用USART_Cmd。
这里有个细节——先配置再使能。我在项目中遇到过,有人先使能了UART再去配参数,结果第一个字节就发乱了。
初始化代码示例(简化版):
void UART_Init(uint32_t baudrate)
{
GPIO_InitTypeDef GPIO_InitStructure = {0};
USART_InitTypeDef USART_InitStructure = {0};
// 1. 使能时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE);
// 2. GPIO配置:TX-PA2, RX-PA3
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
// 3. 复位UART
USART_DeInit(USART2);
// 4. 配置参数
USART_InitStructure.USART_BaudRate = baudrate;
USART_InitStructure.USART_WordLength = USART_WordLength_8b;
USART_InitStructure.USART_StopBits = USART_StopBits_1;
USART_InitStructure.USART_Parity = USART_Parity_No;
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
USART_Init(USART2, &USART_InitStructure);
// 5. 使能接收中断
USART_ITConfig(USART2, USART_IT_RXNE, ENABLE);
NVIC_EnableIRQ(USART2_IRQn);
// 6. 使能UART
USART_Cmd(USART2, ENABLE);
}
3.2 中断接收与轮询发送
接收用中断,发送用轮询——这是工业现场最常用的组合。为什么?
接收用中断:因为你不知道数据什么时候来。用中断,CPU可以干别的事,数据到了再处理。我见过有人用轮询接收,结果CPU占用率飙到80%,别的任务全卡死了。
发送用轮询:发送是主动行为,你知道什么时候发。轮询发送简单可靠,而且发送速度通常比接收快,不会阻塞太久。
中断服务函数里,我建议只做一件事:把收到的字节扔进环形缓冲区。千万别在中断里做复杂处理,比如解析协议、写Flash——那是应用层的事。
中断服务函数模板:
void USART2_IRQHandler(void)
{
if(USART_GetITStatus(USART2, USART_IT_RXNE) != RESET)
{
uint8_t data = USART_ReceiveData(USART2);
RingBuf_Write(&uart_rx_buf, data); // 写入环形缓冲区
}
}
轮询发送就简单了:
void UART_SendByte(uint8_t data)
{
while(USART_GetFlagStatus(USART2, USART_FLAG_TXE) == RESET);
USART_SendData(USART2, data);
}
嗯,这里要注意:发送完成标志(TC)和发送寄存器空标志(TXE)是两回事。TXE表示数据已经移到移位寄存器,可以写下一个字节了。TC表示整个字节已经发送完毕。轮询发送用TXE就够了,效率更高。
3.3 波特率误差分析
波特率误差,这是个老生常谈的问题。CH32V203的UART时钟源来自APB总线,通过分频得到目标波特率。公式很简单:
波特率 = 时钟频率 / (16 × USARTDIV)
但问题是,USARTDIV是整数分频,不一定能精确匹配目标值。比如系统时钟96MHz,想要115200bps:
USARTDIV = 96000000 / (16 × 115200) ≈ 52.0833
取整后是52,实际波特率 = 96000000 / (16 × 52) ≈ 115384.6
误差 = (115384.6 - 115200) / 115200 ≈ 0.16%
这个误差在±2%以内,通信没问题。但有些特殊波特率,比如250000bps,误差可能超过3%,就会出现丢字节。
| 目标波特率 | 实际波特率 | 误差 | 是否可用 |
|---|---|---|---|
| 9600 | 9600 | 0% | ✅ 完美 |
| 115200 | 115384 | +0.16% | ✅ 可用 |
| 250000 | 260869 | +4.35% | ❌ 可能丢数据 |
| 921600 | 923076 | +0.16% | ✅ 可用 |
避坑指南:我曾经在一个项目里用了250000bps的波特率,结果两台设备通信时好时坏。查了两天才发现是波特率误差太大。后来改成230400bps,问题解决。所以,选波特率时最好算一下误差,别想当然。
3.4 环形缓冲区实现与优化
环形缓冲区,说白了就是一个固定大小的数组,加上读写指针。写指针追着读指针转圈。它的好处是:中断里只管写,主循环里只管读,互不干扰。
我常用的环形缓冲区结构体:
typedef struct {
uint8_t buffer[RING_BUF_SIZE];
volatile uint16_t head; // 写指针
volatile uint16_t tail; // 读指针
} RingBuf_t;
读写操作:
// 写一个字节
uint8_t RingBuf_Write(RingBuf_t *rb, uint8_t data)
{
uint16_t next_head = (rb->head + 1) & (RING_BUF_SIZE - 1);
if(next_head == rb->tail) {
return 1; // 缓冲区满
}
rb->buffer[rb->head] = data;
rb->head = next_head;
return 0;
}
// 读一个字节
uint8_t RingBuf_Read(RingBuf_t *rb, uint8_t *data)
{
if(rb->head == rb->tail) {
return 1; // 缓冲区空
}
*data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) & (RING_BUF_SIZE - 1);
return 0;
}
这里有个优化点:缓冲区大小必须是2的幂。这样取模运算可以用位与(&)代替,速度快很多。我一般设成256或512字节,够用又不浪费内存。
优化建议:
- 读写指针加
volatile,防止编译器优化导致中断和主循环不同步。 - 缓冲区大小用宏定义,方便调试时调整。
- 如果数据量很大,可以考虑用DMA+环形缓冲区,CPU几乎零负担。
你想想看,有了环形缓冲区,中断服务函数就变得非常轻量——只写一个字节,然后退出。主循环里再慢慢处理数据。这样既不会丢数据,也不会影响实时性。
最后说一句,UART驱动写好了,后面调试其他外设会轻松很多。打印日志、跟PC通信,全靠它。所以,花点时间把这一块打磨好,绝对值。