适用芯片
| 芯片系列 | 能否直接照抄 | 说明 |
|---|---|---|
| CH32V307 / CH32V305 / CH32V317 | ✅ 主线 | 8 个串口(USART1~3 + UART4~8);库ch32v30x_* |
| CH32V203 / CH32V208等 V20x 系列 | ✅ 直接用 | 4 个串口(USART1~3 + UART4);库ch32v20x_*,位定义和函数名与 V30x 逐行一致 |
| CH32V003 | ✅ 能用,配置要调 | 只有 1 个串口、2 KB RAM;库ch32v00x_*。第 4 节的双缓冲开到 64 B ×2 就够 |
| CH32V103等其它 RISC-V 系列 | ⚠️ 思路通用,名字要核对 | 用各自的 EVT 库(如ch32v10x_*),函数名和寄存器名以该型号头文件为准 |
| CH32F103 / CH32F203等 CH32F 系列 | ⚠️ 思路通用,代码别直接抄 | Cortex-M3 内核,固件库不是这一套 |
上表前三个 ✅ 的系列,下面这些东西是完全通用的:库函数名(USART_Init/USART_ITConfig/USART_DMACmd/USART_GetFlagStatus…)、寄存器命名(STATR/DATAR/CTLR1~3)、中断写法(__attribute__((interrupt("WCH-Interrupt-fast"))))。
串口是单片机最常用的外设,也是最容易卡人的地方:printf打不出来、收数据莫名其妙少几个字节、换块板子全是乱码、接 RS485 发出去的最后一字节被吃掉……
这篇按"能解决什么问题"来写:每一节先说这个功能干什么用,再给能直接抄的代码,最后说坑。文中的库函数名、寄存器名、DMA 通道都对着沁恒官方 EVT 例程核实过。
先记住三句话,后面都好理解:
接收永远是不定长的——你不知道对面什么时候发、发多少。
发送是阻塞的——115200 下 1 KB 要约 90 ms,
printf会把主循环卡死。中断里只搬数据——不解析协议、不
printf、不等标志位。
最快的起步方式:打开官方 EVT 工程里对应的例程(
EVT/EXAM/USART/下,.wvproj双击就能开),直接改User/main.c——库路径、启动文件、链接脚本都已经配好了,别自己从零建工程。
一、printf 怎么打到串口上
有什么用:调试的第一生产力。能把变量打成文本,比点灯强一百倍。
怎么用:官方工程自带debug.c,一行就够:
USART_Printf_Init(115200); /* 默认占用 USART1 / PA9 */ printf("SystemClk:%d\r\n", SystemCoreClock);它为什么能工作(这里最容易看糊涂):printf是 C 库函数,只负责把%d格式化成文本,它不知道文本该往哪送。C 库的规矩是——真正输出时去调底层钩子_write(),而库自带的_write是个空壳。debug.c自己实现了一份,把输出接到 USART1:
__attribute__((used))int_write(intfd,char*buf,intsize) {for(inti =0; i < size; i++) {while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET);USART_SendData(USART1, *buf++); }returnsize;/* 必须返回 size,返回少了 printf 认为写失败 */}一句话分工:USART_Printf_Init管"硬件通不通",_write管"数据往哪走",两个都到位才能出字。
两个坑:
USART_Printf_Init只开了发送(源码里是USART_Mode_Tx)。想拿它当串口初始化去收数据,永远收不到——收数据要自己初始化,把 Rx 打开。它每个字节都等 TC,是阻塞的。别放进中断:一个 20 字符的日志在 115200 下要 1.7 ms,别的中断全被推迟。
附赠一个彩蛋:debug.h里把SDI_PRINT改成SDI_PR_OPEN,printf就走两线调试口输出,在 MounRiver 的调试窗口里直接看,完全不占串口。串口被业务占满时特别好用。
二、最基础的收发:轮询
有什么用:验证通路(接线、波特率对不对)、极低速率场景、临时调试。缺点:死等,主循环被绑住。
voidUSART1_Init(uint32_tbaud){ GPIO_InitTypeDef GPIO_InitStructure = {0}; USART_InitTypeDef USART_InitStructure = {0}; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9;/* TX */GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10;/* RX */GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = baud; 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(USART1, &USART_InitStructure);USART_Cmd(USART1, ENABLE);/* 放最后 */} voidUSART1_SendByte(uint8_tch){USART_SendData(USART1, ch);while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);/* 等寄存器空 */}TXE 和 TC 千万别搞混,这是最经典的 bug:
TXE:数据从
DATAR挪进了移位寄存器,可以放下一个字节了。TC:最后一个位的停止位也发完了。
连续发数据等 TXE 效率高;发完最后一个字节要等 TC,否则紧接着切 RS485 方向、关串口、进低功耗,最后一字节就被切掉。
两个容易忽略的点:USART_Cmd放最后(参数没配完就使能,第一个字节可能是脏的);USART1 在 APB2,USART2/3 和 UART4~8 在 APB1,时钟开错总线是"代码全对但没反应"的典型原因。
三、接收不能靠轮询:中断 + 环形缓冲
有什么用:让接收不再阻塞主循环。中断只把字节扔进缓冲区,主循环按自己的节奏取,缓冲区没满就不丢数据。
#defineRB_SIZE 256/* 取 2 的幂,取模就能用位运算 */#defineRB_MASK (RB_SIZE - 1)typedefstruct{volatileuint16_thead;/* 只由中断改 */volatileuint16_ttail;/* 只由主循环改 */uint8_tbuf[RB_SIZE]; }ring_t; staticring_trb; staticinlineuint8_trb_put(ring_t*r,uint8_tc){uint16_tnext = (uint16_t)((r->head +1) & RB_MASK);if(next == r->tail) {return0;/* 满了,丢弃 */} r->buf[r->head] = c; r->head = next;return1; } staticinlineuint8_trb_get(ring_t*r,uint8_t*c){if(r->head == r->tail) {return0;/* 空 */} *c = r->buf[r->tail]; r->tail = (uint16_t)((r->tail +1) & RB_MASK);return1; } staticinlineuint16_trb_count(constring_t*r){return(uint16_t)((r->head - r->tail) & RB_MASK); } 中断侧这样写:voidUSART1_IRQHandler(void)__attribute__((interrupt("WCH-Interrupt-fast"))); voidUSART1_IRQHandler(void){if(USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) {rb_put(&rb, (uint8_t)USART_ReceiveData(USART1));/* 读 DATAR 顺带清 RXNE */} }三个必须记住的点:
head/tail要加volatile,否则主循环可能一直读寄存器里的旧值,永远看不到中断写的数据。单生产者单消费者不用加锁:中断只改
head,主循环只改tail。两边都改同一个指针就得上临界区。大小按最坏情况算:波特率 × 主循环最长阻塞时间。115200 下主循环有 10 ms 不能处理,就得留 115 字节以上,取 256 比较稳。
CH32 是 RISC-V,中断函数必须带__attribute__((interrupt("WCH-Interrupt-fast")))。少了它编译器不会生成正确的现场保护和返回指令,现象是进中断就死。另外函数名必须和启动文件里的向量表一致(USART1_IRQHandler),改名字不报错但中断永远不进。
四、核心:DMA + 空闲中断收不定长数据
有什么用:这是"不定长 + 不丢字节 + 不占 CPU"三个要求同时满足的标准答案。CPU 平时完全不管串口,一帧收完才被叫醒一次。
原理三句话:
DMA 在后台把每个字节搬进内存,CPU 不参与。
一帧发完,总线空闲超过一个字符时间,硬件自动触发IDLE 中断。
中断里算一下
已收字节数 = 缓冲区大小 − DMA 剩余计数,就是一个减法得到帧长。
怎么用(照官方USART_Idle_Recv的双缓冲写法):
#defineRX_BUF_LEN 128staticuint8_trx_dma_buf[2][RX_BUF_LEN];staticvolatileuint8_trx_dma_idx =0;/* 当前 DMA 在写哪一块 */#defineRX_DMA_CH DMA1_Channel5/* USART1_Rx *//* IDLE 和 DMA 传输完成共用:先换缓冲,再处理旧数据 */staticvoidrx_frame_irq(uint16_tlen){uint8_told = rx_dma_idx; rx_dma_idx = !old; DMA_Cmd(RX_DMA_CH, DISABLE);DMA_SetCurrDataCounter(RX_DMA_CH, RX_BUF_LEN); RX_DMA_CH->MADDR = (uint32_t)rx_dma_buf[rx_dma_idx];/* 换到另一块 */DMA_Cmd(RX_DMA_CH, ENABLE); ring_push_huge(rx_dma_buf[old], len);/* 旧那块慢慢处理 */} voidUSART1_IRQHandler(void)__attribute__((interrupt("WCH-Interrupt-fast"))); voidUSART1_IRQHandler(void){if(USART_GetITStatus(USART1, USART_IT_IDLE) != RESET) {rx_frame_irq((uint16_t)(RX_BUF_LEN - RX_DMA_CH->CNTR));USART_ReceiveData(USART1);/* 清 IDLE:读 STATR 后再读 DATAR */} } voidDMA1_Channel5_IRQHandler(void)__attribute__((interrupt("WCH-Interrupt-fast"))); voidDMA1_Channel5_IRQHandler(void){if(DMA_GetITStatus(DMA1_IT_TC5) != RESET) {DMA_ClearITPendingBit(DMA1_IT_TC5);rx_frame_irq(RX_BUF_LEN);/* 缓冲区被写满,也要取走 */} }初始化里这三件事别漏:
DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)(&USART1->DATAR);/* 是 DATAR,不是 DR */DMA_ITConfig(RX_DMA_CH, DMA_IT_TC, ENABLE);/* 只配 NVIC 不够,DMA 中断源也要开 */USART_ITConfig(USART1, USART_IT_IDLE, ENABLE);/* 开空闲中断 */三个坑,每个都有人栽过:
IDLE 清不掉,中断会一直重入。它的清除条件是"读
STATR+ 读DATAR"这个序列:中断里USART_GetITStatus()已经读了 STATR,必须再补一句USART_ReceiveData()读 DATAR。USART_ClearITPendingBit(USART1, USART_IT_IDLE)对它无效(那只是往 STATR 写 1)。标志没清、IDLEIE还开着时中断线一直有效,现象是反复重入、看起来像卡死。缓冲区被写满时 IDLE 不一定来。DMA 是
DMA_Mode_Normal,搬满就自动关通道,之后的字节不再被搬走。所以必须同时开 DMA 传输完成中断——这也是官方例程里DMA1_Channel5_IRQHandler存在的原因。单缓冲"先拷贝再重启"会丢字节。正确顺序是"先量长度 → 先重启 DMA → 再拷贝";反过来拷贝期间新来的字节会被写到缓冲区尾部,紧接着计数器复位,这些字节就永久丢了。想彻底没有窗口期,就用上面的双缓冲。
双缓冲为什么值那 128 字节:切换MADDR之后 DMA 写的是另一块内存,你处理旧数据时完全没有空窗。数据量大、波特率高的场合这个交换很划算。另外因为数据是累进到环形缓冲的,单帧长度可以超过一块 DMA 缓冲区(一块 128 B,一帧 1000 B 也没问题)。
DMA 通道是硬件固定的,不能随便选:
| 串口 | 发送 | 接收 |
|---|---|---|
| USART1 | DMA1_Channel4 | DMA1_Channel5 |
| USART2 | DMA1_Channel7 | DMA1_Channel6 |
| USART3 | DMA1_Channel2 | DMA1_Channel3 |
五、高效发送:DMA + 发送队列
有什么用:printf发 1 KB 会卡住主循环约 90 ms。要频繁上报数据,就得让 DMA 发、CPU 只管往队列里塞。
思路很直白:队列里存"数据 + 长度",usart_tx_start()启动队首那一项,DMA 传输完成中断里推进tail再启动下一项。
三个容易错的地方:
数据要拷进队列(队列自带缓冲区数组)。如果队列只存指针,那块内存在发完之前不能改,还得额外配内存池和"这一项发完了"的通知——用栈上的临时数组配指针队列,发出去的必然是垃圾。
"判断空闲 + 启动"必须原子。主循环刚判断完
tx_busy == 0、还没启动,DMA 中断进来清标志又启动了一项,主循环回来接着启动——同一项发两遍。用__disable_irq()/__enable_irq()把这一小段夹起来最省事。DMA 的 TC ≠ 线上发完。TC 只代表最后一个字节写进了
DATAR,移位寄存器里可能还有数据。要切 RS485 方向,得用 USART 的 TC 中断。
六、串口怎么配合其他外设
串口 + 定时器:判帧超时
IDLE 是硬件判帧,但有些协议要求明确的帧间隔(Modbus RTU 是 3.5 个字符时间)。做法是:每收到一个字节就把定时器计数清零,定时器溢出说明"这么久没新数据",一帧结束。
时间怎么定:9600 波特率下一个字符约 1.04 ms,3.5 字符 ≈ 3.65 ms,超时取 4 ms 左右。超时要比 3.5 字符略长,否则一帧会被切成两段。
串口 + RS485:方向控制是最大的坑
RS485 半双工,靠 DE 引脚切换收发。经典错误是等 TXE 就切回接收——TXE 只说明数据进了移位寄存器,最后一字节还在线上。正确顺序:
DE_HIGH();while(USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);/* 先等 TXE,顺带清掉残留 TC */USART_SendData(USART1, data);while(USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET);/* 再等这一字节真的发完 */DE_LOW();第一句不能省:复位后 TC 默认就是 1,直接等 TC 会立刻通过,照样把最后一字节切掉。
硬件上顺手记三条:CH32 是 3.3V,优先选 3.3V 收发器(MAX3485 / SP3485 / SN65HVD72),别用 5V 的 MAX485——它的 RO 会把 5V 灌进 CH32 的 RX;DE 加 10k 下拉,防止复位期间 DE 悬空误占总线;120 Ω 终端电阻只在总线的两个物理末端各一颗,不是每个节点一颗。
串口 + ADC:采样上报
关键是"采样和发送解耦":ADC-DMA 在后台不停采,主循环按自己的节奏取最新值打包发出。别用printf发(阻塞),走上面的 DMA 发送队列。注意snprintf截断时返回的是"本想写的长度",要夹到缓冲区大小以内。
串口 + 上位机:Modbus RTU
帧间隔必须守(帧间 ≥3.5 字符时间,帧内字节间隔 ≤1.5 字符时间);CRC 低字节在前,容易写反;CRC 计算别放中断里。自己定协议的话,加帧头 + 长度 + 载荷 + 校验,有长度字段就不用靠超时猜帧长。
串口 + RTOS
中断里只做一件事:xSemaphoreGiveFromISR()给信号量,解析放任务里。另外 CH32 的 RISC-V FreeRTOS port 在临界区是直接全局关 MIE,ARM 那套configMAX_SYSCALL_INTERRUPT_PRIORITY的规则不适用,别硬套。
串口 + IAP 升级
先整包收完、校验通过再写 Flash。注意Flash 擦写期间 CPU 会停(取指也停),中断响应可能延迟到毫秒级:DMA 的目标缓冲区必须在 SRAM,环形缓冲要按一次 Flash 操作的最长时间来留。
七、轮询 / 中断 / DMA 怎么选
| 方案 | CPU 占用 | 适合 | 不适合 |
|---|---|---|---|
| 轮询 | 高(死等) | 调试打印、极低速率 | 主循环有实时要求 |
| 中断 + 环形缓冲 | 每字节一次中断 | 中等速率、协议简单 | 高波特率、大数据量 |
| DMA 发送 | 几乎为 0 | 大数据块、上报日志 | 极低延迟的小包 |
| DMA 接收 + IDLE | 几乎为 0 | 不定长帧、高波特率 | 单字节交互式命令 |
经验:默认从"中断 + 环形缓冲 + 超时判帧"起步;数据量大、波特率上到 921600 就往 DMA 走。115200 是个舒适区。
八、常见问题速查
| 现象 | 最可能的原因 |
|---|---|
| 完全没有反应 | TX/RX 没交叉、没共地、时钟开错总线、USART_Cmd没调用 |
| 全是乱码 | 波特率不匹配、时钟配置不对、串口助手勾了十六进制显示 |
| 偶尔乱码 | 时钟用了内部 HSI(精度低)、RX 悬空、接地不良、线太长 |
| 只收到第一个字节 | IDLE 没按"读 STATR + 读 DATAR"清;收完一帧没重新使能 RXNE |
| 卡在中段像死机 | 中断标志没清,中断反复重入 |
| 收满一块就再没数据 | DMA Normal 模式搬满自停,没开 DMA 传输完成中断 |
| 发出去的数据被截断 | 没等 TC 就切方向 / 关外设 / 进低功耗 |
| 进中断就死 | 中断函数少了WCH-Interrupt-fast属性 |
| 中断永远不进 | 函数名和向量表不一致,或者 NVIC 没使能 |
| DMA 发出来是旧数据 | 发送期间源缓冲区被改写(典型的栈变量问题) |
| 寄存器名对不上 | 从 STM32 抄来的 SR/DR/CR —— CH32 是STATR / DATAR / CTLR1~3 |
附:CH32V003 / CH32V203 的差异
| CH32V307 | CH32V203 | CH32V003 | |
|---|---|---|---|
| 串口数量 | 8 个(USART1~3 + UART4~8) | 4 个(USART1~3 + UART4) | 1 个(USART1) |
| DMA | DMA1 + DMA2 | 有 | 有(DMA1;USART1 用 CH4/CH5) |
| printf 默认引脚 | PA9 | PA9 | PD5(另有重映射档位) |
| 库头文件 | ch32v30x_usart.h | ch32v20x_usart.h | ch32v00x_usart.h |
库函数名、寄存器名、中断属性三个系列通用。CH32V003 只有 2 KB RAM,第 4 节的双缓冲要开小一点(比如 64 B ×2 加 256 B 环形缓冲),别照搬 V307 的配置。
参考资料
官方例程仓库:私信我。串口例程在
EVT/EXAM/USART/:USART_Polling、USART_Interrupt、USART_DMA、USART_Idle_Recv(本文第四节的原型)、USART_Printf。数据手册和参考手册:沁恒官网,重点看USART 章节和DMA 请求映射表。
文中代码基于沁恒官方 EVT 例程与固件库源码整理,具体引脚和 DMA 通道以对应型号的数据手册为准。