经典三问
Q: UART 串口是什么?
A: UART 是按照约定波特率和帧格式,逐位发送和接收异步数据的通信外设。
Q: 为什么需要串口调试?
A: 它能把芯片内部的状态送到电脑,帮助我们在没有屏幕或示波器时确认程序实际运行情况。
Q: UART 常见使用场景?
A: 输出日志、接收命令、连接无线模块、升级固件和与上位机交换文本或二进制数据。
点亮 LED 的时候,板子只会回答你一个问题:它亮了,或者没亮。
OLED 能把一个数字摆在眼前,但当程序开始有状态、有分支、有一串传感器数据时,盯着几颗灯猜程序走到哪一步,很快就不够用了。这个时候我会接一块 CH340。它只做一件事:把电脑 USB 口上的数据,换成 STM32 USART 能看懂的电平;反过来也一样。
今天不搭框架,也不追求一步到位。目标很小,却要真的跑通:按下开发板上的按键,STM32 发出 Hello world,电脑上的 VOFA+ 能收到,OLED 也显示这句话;在 VOFA+ 里发送 How are you?,单片机识别完整的一行,回复:
text
I'm fine, thank you, and you?
再顺手加两条小车命令:发送 LED ON 或 LED OFF,接在 PC13 的外接 LED 跟着改变。串口从"打印窗口"变成一个能控制硬件的入口,就在这一步发生。
不急着写代码,看看这三根线把谁接给了谁
这次用的是 USB-TTL CH340 模块,模块通过电脑的 USB 口供电。要接的只有三根线:
| CH340 | STM32F103C8T6 | 这根线在做什么 |
|---|---|---|
TXD |
PA10 (USART1_RX) |
CH340 发出的数据进入单片机接收脚 |
RXD |
PA9 (USART1_TX) |
单片机发出的数据进入 CH340 接收脚 |
GND |
GND |
两边共用同一个电平参考 |
TX 接对方的 RX,RX 接对方的 TX。两边都把自己的 TX 接自己的 TX,谁也收不到对方的内容;只接信号线不接 GND,电压没有共同的"零",终端可能沉默,也可能吐出乱码。
模块上那几个电源针------VCC、5V、3.3V------一个都不用接到 STM32 上。 道理很简单:CH340 插在电脑 USB 口上,电已经从 USB 那头取到了;STM32 由它自己的电源供电。两边各自有电,要做的只是把信号接过去、把"零"对上。所以一共就三根线:TX、RX、GND。
我想说
跳线帽。最小系统板上天天插着,有什么用呢?
跳线帽就是一个金属套,套在两根相邻的排针上,把那两个点短接在一起------等于一根很短的导线把它们连住了。所以模块上那几个针只是几个接线点,跳帽扣在哪两个针上,哪两个点就通:
- 跳帽扣在
5V和VCC上:模块按 USB 过来的 5V 工作,VCC这个点也跟 5V 通在一起; - 跳帽扣在
3.3V和VCC上:模块按自己稳出来的 3.3V 工作,VCC就跟 3.3V 通在一起。
万一那颗小塑料套找不到了,或者买回来就没带:用一根杜邦线把同样的两个针连起来,效果一模一样。 跳帽干的活只有"短接"这一件,换成一根线,短接照样成立------就是难看一点,也更容易松。
不同模块的丝印和排针顺序不完全一样,有的把这几根针放在侧面,有的标成 VCCIO 之类的名字,道理是通用的。
VOFA+ 这边先摆好尺子
VOFA+ 前面已经装好、界面那几块也已经认过了,这里不再重复介绍------直接用它。打开软件,在数据源里选 CH340 出现的那个 COM 口(如果 COM 口十分的多,不容易分辨,可以先拔掉CH340,看看原来有哪些,再插上去,多出来那一个就是),参数设置成:
text
波特率:115200
数据位:8
停止位:1
校验位:None
流控:None
也就是常写的 115200 8N1。这一篇先在终端窗口里看文本,波形区暂时不用:今天发的是一行行给人读的句子,还不到画曲线的时候;曲线要等发送端按绘图格式把数字排好,那是下一步的事。
发送设置里先勾上追加 \r\n。这两个字符不是装饰:\r 表示回车,\n 表示换行,单片机用它们判断"一行命令到这里结束了"。如果 VOFA+ 只发 How are you?,程序会一直等;发 How are you?\r\n,程序才会把这一行交给解析函数。
USART1 的初始化单子
PA9 和 PA10 并不是普通 GPIO 里"突然长出串口功能"的引脚。我们要先打开 GPIOA 和 USART1 的时钟,再让 PA9 交给 USART 外设控制,PA10 作为输入,最后把双方都同意的通信参数写进 USART1。
c
void Uart_Init(void)
{
GPIO_InitTypeDef gpio;
USART_InitTypeDef uart;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA |
RCC_APB2Periph_USART1, ENABLE);
gpio.GPIO_Pin = GPIO_Pin_9;
gpio.GPIO_Mode = GPIO_Mode_AF_PP;
gpio.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &gpio);
gpio.GPIO_Pin = GPIO_Pin_10;
gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &gpio);
uart.USART_BaudRate = 115200; /* 波特率 */
/*
特别重要 很容易设置错 常见的有
... 9600 19200 115200 921600 1152000 ...
*/
uart.USART_WordLength = USART_WordLength_8b; /* 数据位 */
uart.USART_StopBits = USART_StopBits_1; /* 停止位 */
uart.USART_Parity = USART_Parity_No; /* 校验位 */
uart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; /* 流控 */
uart.USART_Mode = USART_Mode_Tx | USART_Mode_Rx;
USART_Init(USART1, &uart);
USART_Cmd(USART1, ENABLE);
}
这里的 GPIO_Mode_AF_PP 可以先当成一句完整的话来读:PA9 不再由我们的 GPIO_SetBits() 直接拨电平,而是交给 USART1 的发送电路,使用复用推挽输出把一位一位的数据送出去。PA10 是接收脚,外部 CH340 改变它的电平,USART1 在内部把这些变化还原成字节。
这几句初始化代码不会直接出现在 main.c 的每个角落。main() 只需要调用:
c
Uart_Init();
一帧数据到底长什么样
那串 8N1 不是随口起的外号,它描述的是一帧数据在线上的排布。
串口线没人说话的时候停在高电平 ,这叫空闲。要发一个字节,发送方先把线拉低一位的时间 ------这是起始位,等于通知对方"注意,开始"。
接着是 8 个数据位 ,低位先走。拿 'H' 举例,它的 ASCII 码是 0x48,二进制是 0100 1000;线上实际按 0、0、0、1、0、0、1、0 的顺序一位一位过去。收完数据位,是我们没用上的校验位 ,最后停止位把线拉回高电平,这一帧结束。
位有多长,由波特率决定。 115200 表示每秒 115200 个位,一位约 8.7µs;9600 则是每位约 104µs。两边定的时间不一样,接收方就会在错误的时间点去采样,读到的不是"某个字母错了",而是整行不可读。
这也解释了为什么参数要一个字一个字地对:波特率、数据位、校验位、停止位,任何一项不一致,双方对"每一位占多长时间、一帧有多长"的理解就错开了,后面的内容全废。
先让一个字节离开芯片
发送函数暂时采用轮询。USART1 的发送数据寄存器空了,就塞下一个字节:
c
void Uart_SendByte(uint8_t data)
{
while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET)
{
}
USART_SendData(USART1, data);
}
void Uart_SendString(const char *text)
{
while (*text != '\0')
{
Uart_SendByte((uint8_t)*text);
text++;
}
}
TXE 表示发送数据寄存器可以接收下一个字节,不表示最后一位已经从 PA9 走完。现在我们只是连续发一行文本,不需要在这里等待整帧结束;等到必须确认"整个发送过程真的结束了"时(例如收发共用同一对线、不确认发完就不能开始收),才去看 TC 这个标志。
接收也先保持简单:检查 RXNE,有字节就取走,没有就立即返回。主循环可以继续做别的事情:
c
uint8_t Uart_ReceiveByte(uint8_t *data)
{
if (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == RESET)
{
return 0;
}
*data = (uint8_t)USART_ReceiveData(USART1);
return 1;
}
显然它会出现这样的问题:如果主循环长时间忙着做别的事,硬件接收寄存器里的字节可能来不及取走,这时新到的字节就无处可放、被丢掉(ORE 置位),而读 USART_DR 读到的还是先前那颗旧的字节。如果一直 while(1) 读取的话,接收代码本身又会阻塞其他的代码。
按键按下,发送 Hello world
按键模块挂在 1ms 节拍上------上一回那个 TIM1_UP_IRQHandler 里,Key_Tick() 和 Mode_Tick() 并排。主循环只做两件事:取它交出来的事件、把串口收到的字节收集成行。按键这一次不自己判电平,直接用事件:
c
static KeyEvent event;
while (Key_GetEvent(&event) != 0U)
{
if ((event.id == KEY_A) && (event.type == KEY_EVENT_CLICK))
{
Uart_SendString("Hello world\r\n");
OLED_Clear();
OLED_ShowString(0, 0, "Hello world", OLED_8X16);
OLED_Update();
}
}
Key_Tick() 每 1ms 被中断叫一次,消抖、单击、双击、长按都在那个模块里解决;这里只认一件事------A 被单击了。事件接口的用法和前面一样,不重复讲。
这里没有上电自动刷屏。手指按下 PB11 接地的按键,电脑端才收到一行 Hello world,OLED 同时显示同样的内容。这样屏幕上的画面和串口窗口是同一次动作留下的两个反馈,不需要猜"刚才那次下载到底有没有跑到这里"。
OLED_ShowString() 在显示按键状态那几篇里就出现过了,它把一串英文字符变成屏幕上的像素;这次只是换了要显示的句子。
接收 How are you?,先等一整行再回答
单片机不能把电脑发来的字节当成一句完整的话。H、o、w......每个字符都是分开到达的,我们在数组里逐个放进去,等 \r 或 \n 出现,再补上字符串结束符 \0:
c
#define COMMAND_BUFFER_SIZE 32U
static char command[COMMAND_BUFFER_SIZE];
static uint8_t command_length = 0U;
c
static uint8_t received_byte;
static void HandleCommand(const char *cmd); /* 定义在下面,先声明一下 */
while (Uart_ReceiveByte(&received_byte))
{
if ((received_byte == '\r') || (received_byte == '\n'))
{
if (command_length > 0U)
{
command[command_length] = '\0';
HandleCommand(command);
command_length = 0U;
}
}
else if (command_length < (COMMAND_BUFFER_SIZE - 1U))
{
command[command_length] = (char)received_byte;
command_length++;
}
else
{
command_length = 0U;
}
}
收到 \r\n 时,第一次遇到 \r 已经完成解析,紧跟着的 \n 会被忽略,因为缓冲区长度已经归零。当然也可以改成同时接收到两者才算正常解析,我这里先这样吧,VOFA+ 的换行设置可以保留,不需要为了躲开它而在上位机里手动删除字符。
解析函数现在只认三句话,认不出来就回一句提示:
c
static void HandleCommand(const char *cmd)
{
if (strcmp(cmd, "How are you?") == 0)
{
Uart_SendString("I'm fine, thank you, and you?\r\n");
OLED_Clear();
OLED_ShowString(0, 0, "How are you?", OLED_6X8);
OLED_ShowString(0, 16, "I'm fine, thank", OLED_6X8);
OLED_ShowString(0, 32, "you, and you?", OLED_6X8);
OLED_Update();
}
else if (strcmp(cmd, "LED ON") == 0)
{
LED_WriteOne(0, 1U);
Uart_SendString("LED ON\r\n");
}
else if (strcmp(cmd, "LED OFF") == 0)
{
LED_WriteOne(0, 0U);
Uart_SendString("LED OFF\r\n");
}
else
{
Uart_SendString("Unknown command\r\n");
}
}
strcmp() 是标准库里的字符串比较函数,两个字符串完全一样时返回 0,所以这里用 == 0 判断"就是这句话"。
注意这里的字符串是严格匹配 。How are you? 中多一个空格、少一个问号,都会落到最后那个"认不出来"的分支。先接受这种笨拙,是因为它把"串口收到了字节"和"程序理解了命令"分成了两件可观察的事:收到 Unknown command 说明通信链路是好的,只是这句话不在名单上;连提示都没有,才要回头查线。等命令数量多起来,再引入命令表、前缀匹配和校验,不必现在就把所有问题揉成一个函数。
我会按这个顺序做实验
先做 CH340 回环,不接 STM32
把 CH340 的 TXD 和 RXD 直接短接,GND 不需要接开发板。VOFA+ 选择这个 COM 口,发送一串文字。如果窗口能收到自己发出的内容,说明电脑 USB、CH340 驱动、COM 口和 VOFA+ 参数至少已经互相听得懂。
我之前就遇到收发都是乱码,我一直在排查软件的问题,波特率对不对得上啊,代码写得对不对啊这些,但是一直找不到,收发一直是乱码,后面我直接受不了了,TXD 和 RXD 直接短接,好家伙,自己给自己发也还是乱码,链路是电脑发送数据-TX-短接线-RX-电脑接收显示,全程只有 CH340 自己,这都能乱码,就像是自己想说:"大狗大狗",结果自己听到自己喊出来的却是大狗叫一样。
接上板子,按键只做一件事
按 TXD -> PA10、RXD -> PA9、GND -> GND 接好,烧录工程,VOFA+ 保持 115200 8N1。按一次 PB11 按键,窗口出现:
text
Hello world
OLED 也显示 Hello world。如果屏幕有字、窗口没字,优先查 PA9、RXD 和 GND;如果窗口有字、屏幕没字,先查 OLED 自己的供电和 PB8/PB9,不要把两个问题混在一起。
发送问候语
在 VOFA+ 的发送框输入 How are you?,发送设置追加 \r\n。窗口应该收到:
text
I'm fine, thank you, and you?
OLED 上会把收到的问题放在第一行,把回复拆成两行显示。这里故意把字符串显示和串口回复同时保留:电脑看到的是通信结果,屏幕看到的是单片机内部已经完成了解析。
让串口控制外接 LED
发送 LED ON,PC13 外接 LED 点亮;发送 LED OFF,LED 熄灭。LED_WriteOne(0, 1U) 的意思是"把第 0 颗灯写成亮"------PC13 就是三颗外接灯里的第 0 颗,这个写引脚的函数在前面那套 LED 驱动里。串口只是把命令送到程序,真正改变引脚的仍然是 LED 模块自己的函数。
乱码不是一种故障,先判断它在哪一段坏了
回环也乱码:先看 VOFA+ 和波特率
单片机和 CH340 都按照 115200 发,VOFA+ 却设成 9600,接收到的每一位都会被按错误的时间切片。结果整行都不可读------不是某几个字母错,是每一位都被切在了错误的位置上。回到 CH340 回环,把上位机参数先调通。
回环正常,接板子乱码:看 TX/RX、GND 和电平
TX/RX 接反、GND 没共地,都会让问题从"电脑端"移动到"物理连接"。不要同时改初始化代码和接线;一次只动一个变量,现象才有意义。
只能发不能收:看 PA10 和行结束符
PA9 发送正常不代表 PA10 接收正常。确认 CH340 的 TXD 确实去了 PA10,并确认 VOFA+ 追加的是 \r\n。程序按整行解析,如果你只发送字符却不发送结束符,它会一直把字符放进缓冲区,看起来就像"接收失灵"。
功能越加越多,串口反而不灵:先怀疑节拍和阻塞
这里采用轮询发送,每个字节都要等 TXE。如果把 Uart_SendString() 放进 1ms 定时器中断,或者在主循环里连续打印大量日志,按键、OLED 和电机任务都会被拖住------发送这件事得搬出主循环,这也是串口做大以后必然会走到的一步。
最后橘猫说
当前工程的接收还是最直接的轮询:主循环转到那一步,才去看一眼有没有新字节。主循环转得勤,一切都好;可如果电脑连续高速发送,而主循环又正好忙着刷 OLED,中间的字节就可能被漏掉。
我仍然把它留在第一篇,因为现在最值得确认的是这两条链路:
text
按键动作 -> USART1 TX -> CH340 -> VOFA+
VOFA+ -> CH340 -> USART1 RX -> 行缓冲区 -> 命令函数 -> LED/OLED/回复
这两条路跑通之后,升级目标也就自然清楚了:接收数据的速度超过主循环能及时处理的速度时,就得让接收不再依赖主循环跑得多勤。
串口是一条路:把芯片内部状态送到电脑,也把电脑命令送回芯片。先让这条路上的每一根线和每一个字节都能被看见,再谈协议和无线连接。