公司动态
STM32串口通信详解:从寄存器到printf重定向
串口是嵌入式开发里用得最多的通信方式——没有之一。调试靠它打印log、和上位机通信靠它、连WiFi模块ESP8266也靠它。这篇从UART协议原理讲到printf重定向帮你彻底搞定串口通信。一、为什么串口这么重要你做嵌入式开发90%的调试都依赖串口。程序跑没跑通串口打印一下。传感器读对没串口打印一下。哪里出了Bug串口打印一下。没有串口的嵌入式开发就像闭着眼睛开车。串口UART是最简单、最稳定、最通用的通信方式只需要3根线TX、RX、GND不需要时钟线靠约定波特率几乎所有MCU都有串口外设电脑用USB转TTL就能连二、UART协议原理2.1 UART是什么UARTUniversal Asynchronous Receiver/Transmitter通用异步收发器。关键词是异步——没有时钟线收发双方靠约定相同的波特率来对齐数据。2.2 通信连接STM32 USB转TTL模块 电脑 ┌──┐ ┌──┐ ┌──┐ │ │ TX ──────► RX │ │ │ │ │ │ │ USB │ │ │ │ RX ◄────── TX │◄─────────►│ │ │ │ │ │ │ │ │ │ GND ────── GND│ │ │ └──┘ └──┘ └──┘注意TX接RXRX接TX交叉连接。GND必须共地。2.3 数据帧格式一个UART数据帧的组成空闲 起始位 数据位(8位) 校验 停止位 ‾‾‾‾‾ ‾ D0 D1...D7 P ‾‾ ┌──┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌──┐ ‾‾‾‾‾‾‘ └‘ └‘ └─┘ └─┘ ... └─┘ └─┘ └ └‾‾‾‾ 起始 D0 D1 D7 P 停止 (低) (高)组成部分说明空闲状态总线保持高电平起始位1位低电平标志数据开始数据位5-9位通常用8位校验位奇校验/偶校验/无校验通常用无停止位1或2位高电平通常用1位最常用配置8位数据位 无校验 1位停止位简称8N1。2.4 波特率因为UART是异步通信收发双方必须用相同波特率否则数据会乱码。常用波特率9600、115200最常用、460800、921600。波特率误差不能超过±2%否则采样错位数据全乱。这也是为什么高波特率建议用外部晶振而不是内部RC振荡器。三、STM32串口配置三种方式以USART1PA9-TX, PA10-RX115200波特率8N1为例。3.1 寄存器版voiduart1_init(void){/* 1. 开时钟USART1和GPIOA都在APB2 */RCC-APB2ENR|(12);/* GPIOA时钟 */RCC-APB2ENR|(114);/* USART1时钟 *//* 2. 配置PA9(TX)为复用推挽输出PA10(RX)为浮空输入 */GPIOA-CRH~(0xFF4);/* 清零PA9、PA10配置 */GPIOA-CRH|(0x0B4);/* PA9: 复用推挽输出50MHz */GPIOA-CRH|(0x048);/* PA10: 浮空输入 *//* 3. 配置波特率115200系统时钟72MHz *//* BRR 72MHz / 115200 625 0x271 */USART1-BRR625;/* 4. 使能发送和接收 */USART1-CR1|(13);/* TE: 发送使能 */USART1-CR1|(12);/* RE: 接收使能 */USART1-CR1|(113);/* UE: USART使能 */}/* 发送一个字节 */voiduart_send_byte(uint8_tdata){while(!(USART1-SR(17)));/* 等待发送数据寄存器空(TXE) */USART1-DRdata;}/* 接收一个字节 */uint8_tuart_recv_byte(void){while(!(USART1-SR(15)));/* 等待读数据寄存器非空(RXNE) */returnUSART1-DR;}3.2 标准库版voiduart1_init(void){GPIO_InitTypeDef GPIO_InitStruct;USART_InitTypeDef USART_InitStruct;/* 1. 开时钟 */RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1|RCC_APB2Periph_GPIOA,ENABLE);/* 2. 配置引脚 */GPIO_InitStruct.GPIO_PinGPIO_Pin_9;/* PA9 TX */GPIO_InitStruct.GPIO_ModeGPIO_Mode_AF_PP;/* 复用推挽输出 */GPIO_InitStruct.GPIO_SpeedGPIO_Speed_50MHz;GPIO_Init(GPIOA,GPIO_InitStruct);GPIO_InitStruct.GPIO_PinGPIO_Pin_10;/* PA10 RX */GPIO_InitStruct.GPIO_ModeGPIO_Mode_IN_FLOATING;/* 浮空输入 */GPIO_Init(GPIOA,GPIO_InitStruct);/* 3. 配置USART */USART_InitStruct.USART_BaudRate115200;USART_InitStruct.USART_WordLengthUSART_WordLength_8b;USART_InitStruct.USART_StopBitsUSART_StopBits_1;USART_InitStruct.USART_ParityUSART_Parity_No;USART_InitStruct.USART_HardwareFlowControlUSART_HardwareFlowControl_None;USART_InitStruct.USART_ModeUSART_Mode_Tx|USART_Mode_Rx;USART_Init(USART1,USART_InitStruct);/* 4. 使能 */USART_Cmd(USART1,ENABLE);}/* 发送一个字节 */voiduart_send_byte(uint8_tdata){USART_SendData(USART1,data);while(USART_GetFlagStatus(USART1,USART_FLAG_TXE)RESET);}3.3 HAL库版CubeMX生成CubeMX配置USART1后自动生成MX_USART1_UART_Init()你只需调用/* 发送一个字节 */HAL_UART_Transmit(huart1,data,1,100);/* 发送字符串 */HAL_UART_Transmit(huart1,(uint8_t*)Hello\r\n,7,100);/* 接收一个字节 */HAL_UART_Receive(huart1,data,1,100);三种方式对比和点灯那篇类似寄存器底层、标准库过渡、HAL库主流。日常开发用HAL库理解原理看寄存器。四、发送字符串和数字光会发一个字节不够实际开发要发字符串、发数字。4.1 发送字符串voiduart_send_string(UART_HandleTypeDef*huart,constchar*str){while(*str){HAL_UART_Transmit(huart,(uint8_t*)str,1,100);str;}}/* 使用 */uart_send_string(huart1,Hello Embedded!\r\n);4.2 发送数字voiduart_send_number(UART_HandleTypeDef*huart,intnum){charbuf[16];sprintf(buf,%d\r\n,num);uart_send_string(huart,buf);}/* 使用 */uart_send_number(huart1,12345);这种方式能用但每次都要手动调函数不够方便。有没有更优雅的方式有——printf重定向。五、printf重定向本文重点5.1 为什么要重定向printf在电脑上写C程序printf会输出到屏幕。但STM32没有屏幕printf默认不知道往哪输出。重定向就是告诉printf“你不用往屏幕输出了往串口输出吧。”重定向后你就可以这样写printf(温度: %.1f°C, 湿度: %.1f%%\r\n,temp,humi);intcount10;printf(计数: %d\r\n,count);比手动调uart_send_string方便100倍。5.2 重定向实现HAL库版只需重写fputc函数#includestdio.h/* 必须包含 */externUART_HandleTypeDef huart1;/* 你的USART1句柄 *//* 重定向fputc到USART1 */intfputc(intch,FILE*f){HAL_UART_Transmit(huart1,(uint8_t*)ch,1,100);returnch;}5.3 Keil中必须勾选微库在Keil里使用printf必须勾选Use MicroLIB否则会卡死或链接报错Keil设置步骤 Options for Target → Target标签页 → 勾选 Use MicroLIB什么是半主机模式半主机模式是ARM的一种调试机制标准C库的printf会把输出通过调试器J-Link/ST-Link转发到电脑的调试窗口而不是通过串口。但脱离调试器后程序会卡在等待调试器响应的BKPT指令上导致死机。MicroLIB是Keil提供的精简版C库去掉了半主机模式依赖printf直接走我们重定向的fputc函数通过串口输出体积也小很多。所以勾选MicroLIB就解决了半主机模式卡死的问题。5.4 标准库版重定向如果你用标准库SPL重定向代码略有不同#includestdio.hintfputc(intch,FILE*f){while(USART_GetFlagStatus(USART1,USART_FLAG_TXE)RESET);USART_SendData(USART1,(uint8_t)ch);returnch;}5.5 printf的坑浮点数不显示STM32用MicroLIB时printf默认不支持%f浮点输出printf(%.1f, 3.14)会打印出%f或空。原因MicroLIB为了省体积去掉了浮点格式化功能。这不是FPU硬件浮点单元的问题是C库实现的问题。解决方法1Keil不勾选MicroLIB改用标准库Options for Target → Target标签页 → 取消勾选 Use MicroLIB但标准库需要额外处理半主机模式加以下代码#pragmaimport(__use_no_semihosting)/* 禁用半主机模式 */struct__FILE{inthandle;};FILE __stdout;void_sys_exit(intx){xx;}/* 半主机模式需要的空实现 */解决方法2用sprintf转换再发最简单推荐新手charbuf[32];sprintf(buf,温度: %.2f°C\r\n,temp);/* sprintf在MicroLIB下支持%f */uart_send_string(huart1,buf);注意标准库的sprintf也支持%fMicroLIB的sprintf也支持%f只有MicroLIB的printf不支持。所以用sprintf转成字符串再用printf发字符串是最省心的方案。六、中断接收实用进阶上面都是发送实际项目里接收用中断更高效不用死等。6.1 为什么用中断【轮询方式】主循环里一直查有没有数据 → 浪费CPU 【中断方式】来数据了MCU自动跳到中断函数处理 → CPU该干嘛干嘛6.2 HAL库中断接收实现第1步CubeMX里开启USART1全局中断NVIC Settings → USART1 global interrupt → Enable第2步启动中断接收uint8_trx_buf[1];/* 在main函数里启动中断接收 */HAL_UART_Receive_IT(huart1,rx_buf,1);第3步重写接收完成回调函数/* HAL库会自动调用这个函数名字不能改 */voidHAL_UART_RxCpltCallback(UART_HandleTypeDef*huart){if(huart-InstanceUSART1){/* 收到一个字节存入缓冲区 */printf(收到: 0x%02X\r\n,rx_buf[0]);/* 重新启动下一次接收必须 */HAL_UART_Receive_IT(huart1,rx_buf,1);}}注意HAL库的中断接收是一次性的收到1个字节就停。必须在回调函数里重新调用HAL_UART_Receive_IT否则只能收一次。6.3 环形缓冲区进阶实际项目里收到的数据不止1字节比如ESP8266的AT响应可能50字节需要用环形缓冲区缓存数据。为什么不用普通数组因为串口数据是持续流入的普通数组写到末尾就满了。环形缓冲区通过写指针head和读指针tail在环上追逐的方式让旧数据被自动覆盖实现无限循环接收。就像一个圆形跑道head在前面跑写入tail在后面追读出到末尾了绕回开头继续。#defineBUF_SIZE256uint8_tring_buf[BUF_SIZE];volatileuint16_tbuf_head0;/* 写位置中断里写入 */volatileuint16_tbuf_tail0;/* 读位置主程序读出 */voidHAL_UART_RxCpltCallback(UART_HandleTypeDef*huart){if(huart-InstanceUSART1){ring_buf[buf_head]rx_buf[0];/* 存入环形缓冲 */buf_head(buf_head1)%BUF_SIZE;HAL_UART_Receive_IT(huart1,rx_buf,1);/* 重新启动接收 */}}/* 从环形缓冲区读一个字节 */int8_tring_buf_read(uint8_t*data){if(buf_headbuf_tail)return-1;/* 空 */*dataring_buf[buf_tail];buf_tail(buf_tail1)%BUF_SIZE;return0;}环形缓冲区是嵌入式面试常考点原理head追tail用取模实现循环。ESP8266、GPS模块这种不定长数据流接收都用它。七、常见踩坑坑1串口助手收到乱码现象串口助手显示一堆乱码或问号。排查顺序波特率对不对代码115200助手也必须115200GND接没接TX/RX接了但GND没共地必乱码TX/RX接反STM32的TX要接TTL模块的RX时钟配错系统时钟72MHz配成了8MHz波特率计算就错了最常见的坑系统时钟没配默认8MHz跑但波特率按72MHz算差9倍必乱码。坑2printf无输出现象调了printf但串口助手什么都收不到。排查勾选MicroLIB了吗Keil最容易漏的fputc函数写了吗只include了stdio.h不够USART句柄对不对huart1别写成huart2先用HAL_UART_Transmit测试如果这个能发但printf不能说明是重定向问题坑3接收中断只触发一次现象第一次收数据正常后面就没反应了。原因HAL库的HAL_UART_Receive_IT是一次性的回调函数里忘了重新启动。解决在HAL_UART_RxCpltCallback里必须再次调用HAL_UART_Receive_IT(huart1,rx_buf,1);/* 重新启动 */坑4高波特率921600出错现象115200正常换921600就丢字节。原因内部RC振荡器精度不够±3%高波特率误差超2%中断处理太慢来不及处理上一个字节解决换外部晶振8MHz或16MHz高波特率改用DMA接收不依赖中断八、完整示例串口收发回显把上面学的串起来做一个收到什么就回发什么的回显程序#includemain.h#includestdio.hUART_HandleTypeDef huart1;uint8_trx_buf[1];intfputc(intch,FILE*f){HAL_UART_Transmit(huart1,(uint8_t*)ch,1,100);returnch;}voidHAL_UART_RxCpltCallback(UART_HandleTypeDef*huart){if(huart-InstanceUSART1){/* 回显收到的字节 */HAL_UART_Transmit(huart1,rx_buf,1,100);/* 重新启动接收 */HAL_UART_Receive_IT(huart1,rx_buf,1);}}intmain(void){HAL_Init();SystemClock_Config();MX_GPIO_Init();MX_USART1_UART_Init();printf( STM32 UART Echo Demo \r\n);printf(输入任意字符将原样返回\r\n);HAL_UART_Receive_IT(huart1,rx_buf,1);/* 启动中断接收 */while(1){/* 主循环空闲可以做其他事 */HAL_GPIO_TogglePin(GPIOC,GPIO_PIN_13);HAL_Delay(500);}}这个例程包含了串口初始化 printf重定向 中断接收 回显。理解透这个串口通信就入门了。九、总结要点内容UART核心异步通信约定波特率3线TX/RX/GND数据帧起始位 8数据位 停止位8N1最常用三种开发方式寄存器底层/ 标准库过渡/ HAL库主流printf重定向重写fputc 勾选MicroLIB中断接收HAL_UART_Receive_IT 回调里重新启动常见坑波特率、GND、MicroLIB、中断重启一句话总结串口是嵌入式调试的命脉printf重定向 中断接收这两招学会调试效率翻10倍。下一篇预告《STM32定时器详解从延时到PWM输出》如果这篇文章对你有帮助点赞 收藏 关注这是我持续更新的动力有问题欢迎评论区交流我会逐条回复。作者嵌入式阿蔡