1.4 UART通信 - 实验篇
1.4 UART通信 - 实验篇
Sarzn这个实验中我们要使用 UART 进行点灯
- 让单片机通过 UART 给电脑发送消息
- 在电脑上给单片机的 UART 串口发信号, 检测到是开灯信号的时候将小灯打开
Step 0 - 接线
我们用来进行烧录的 daplink 同样具有 TTL 通信的功能, 如果你不使用 daplink 而是用的 stlink 那么你需要一个 USB 转 TTL 模块 , 让你的电脑接受到串口信号
什么是 TTL ? - TTL 指的是 TTL 电平(晶体管-晶体管逻辑电平) - USB 转 TTL 模块它的主要作用是让电脑和单片机进行对话: 电脑的 USB 信号不能直接接到单片机的引脚上. 这个模块将 USB 信号转换成标准的 TTL 串口信号(通常包含 TXD 发送引脚和 RXD 接收引脚
如果你使用 daplink, 直接将 daplink 上的 Rx 与单片机的 Tx 相连, Tx 与 Rx 相连(单片机上的 Tx 和 Rx 在哪请看下文)
如果你使用 stlink, 将 USB 转 TTL 模块的Rx 与单片机的 Tx 相连, Tx 与 Rx 相连, GND 相连, 然后将它的另一端插到电脑上
串口助手: 这是一个你需要在电脑上下载的软件用于串口连接, 你随便在浏览器里面搜索串口助手然后下载一个就行 如果你是 MAC 用户, 我推荐使用CoolTrem, 当然他也有 Windows 版本的
Step 1 - CubeMX 配置
这里默认你前面已经好好学了, 我们新建一个工程, 因为我们要点灯所以使能一个 LED 灯的 GPIO 口. 再进行一些基础配置之后来到了这个实验的重头戏 – UART
- 打开 Connectivity 选项, 选择USART1并且将它的模式选为
Asynchronous(异步)
这里我们发现并没有一个 UART 设备, 而都是 USART 设备, 在原理篇中其实我们已经做了解释. USART 设备与 UART 的差别在于他不仅可以像UART 那样使用异步模式, 他还可以使用Synchronous(同步)模式, 那么这里我们将它的模式选择为异步
- 在参数设置中配置 USART1 设备, 将比特率配置为 115200
bits/s(你也可以选别的数值), Word Length配置为 8(注意这个 Word
Length包含校验位) 然后调整为 8N1 模式,
最后确定你选择的是 Receive and Transmit(即收又发), 过采样保持 16
就行
配置好 USART1 这个外设之后你就会发现右侧的芯片图多使能了两个 GPIO口, 这个就是我们 daplink 要连接的地方
Step 2 - 写代码
配置完成之后点击生成代码, 在 Clion 中打开main.c 文件
- 我们要在 PV(private variables)
中定义两个自己的数组用来储存发送或者接受的数据
这个 uint_8 是什么意思?为什么不直接写 int?
因为 int 的大小是取决于编译器和硬件架构的, 他不控, 比如在你的 x86/ARM 电脑中 int 的大小是 4 字节. 但是在 8 位单片机中(51 单片机) int 的大小是 2 字节. stm32 是 32 位单片机int 的大小也是 4 字节, 但是为了规范我们在嵌入式开发中不会使用 int
uint_8 是指定他使用了 8 bit, 也就是 1 Byte uint_16 是指定他使用了 16 bit, 也就是 2 Byte
所以回过头来看uint_8 RxData[10] 是在说创建一个 RxData 的数组, 数组的每个元素的大小是一字节, 总共有 10 个元素
\r(0x0D):光标回到行首\n(0x0A):光标移到下一行 那么 uint_8 TxData[10] = “hello”; 的内存分布就是与此同时我们要注意C 语言字符串在末尾会自动加上 \0 表示终止, 这个内存试图没有看出来我也不知道是怎么回事, 但是这个 \0 也就是在下标 7 的位置他是真正存在的

Step 2.1 - 普通阻塞式
来到while 循环, 我们使用HAL_UART_Transmit
1
HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size, uint32_t Timeout)
来进行阻塞式的发送信息, Size 表示要发送多少个字节, Timeout 表示设定一个最长的等待时间去等待 UART 硬件状态标志位置位, MCU 操作寄存器的速度十分的快速, 然后 UART 外设需要将数据层寄存器中取出然后发送出去, 这个就是 MCU 在等待的时间, 也就是”阻塞”
这里表示从 uart1 发出, 发送的数据是 TxData, 发送 7 个字节(7 字节就行, 因为后面的都是我们不需要的, 你也可以看看把它填成 8 会出现什么情况, 是不是会多一些点出来), 最长等待时间 1000 ms
1
HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout)
这个接受数据HAL_UART_Receive() 也是同理
这个接受函数 Timeout 等待的是发送方. 比如你这里写你要接受 5 个字节, 但是发送方只发送了 4 个字节, 如果没有 Timeout, MCU 就会一直傻等知道发送方把最后一个字节给发送过来, 有 Timeout 的话超过了这个时间 MCU 就不会再等了, 超时后就带着那四个字节返回一个HAL_TIMEOUT来告诉你他超时了. 这些你都可以在 CLion 中跳转到这个函数的用例中看到
- 这里的逻辑是我们接受的数据中如果第一位是 ‘1’ , 那么就把灯的电平反转, 然后再将第一位置为 0 . 所以只要我们在串口助手中输入 1 那么我们的灯就会点亮, 再发送一个 1 灯熄灭
1 | /* Private variables ---------------------------------------------------------*/ |
现在插上线来看看你的阻塞式效果如何吧!
现在我们可以再试一试, 如果长按键盘上的 1, 也就是快速的一直发 1 会有什么反应, 你的 hello 是不是变快了?(如果不好观察的话将这个去掉)想想为什么, 和阻塞有关系吗?可以把回答写在评论区
Step 2.2 - 普通中断式
在这个实验中我们要实现 UART 随意的收发定长的数据
- 我们首先要把 USART1 的中断开启, (中断由 NVIC 控制你还记得吗?), 来到
NVIC 设置, 将他选择为
Enable
- 然后再重新生成代码, 用 Clion 打开
我们发现启用之后stm32f1xx_it.c,
中断向量层出现了这个ISR(中断服务函数)
第 0 步, 写PV
在 PV(Private variables) 中定义两个数组, 用于存储要收发的数据
1
2
3
4/* USER CODE BEGIN PV */
uint8_t RxData[10];
uint8_t TxData[10] = "hello";
/* USER CODE END PV */
第 1 步, 开启一次中断式接受
我们要开启中断式接受(注意, 如果只写一个那么接受只能进行一次,
所以我们可以在这个回调函数中再次开启一次接受, 用来接受下一次信息), 在
while(1), 之前的 user code2
中
1 | /* USER CODE BEGIN 2 */ |
这里就解释了为什么我们只能收发定长的数据, 因为这个函数的参数是 5, 这表明, 只有接受到 5 字节的数据之后, 他才会执行你接下来写的回调函数中断. 而它的非阻塞中断是体现在收到完整的一个字节才叫 CPU 来读, 而不是让 CPU 等着读(阻塞), 如果还不懂就回原理篇看看文末的图文
第 2 步, 实现这个中断式接受的回调函数
这个回调函数叫做HAL_UART_RxCpltCallback那么我们去实现它, 这里要说明, 之前我们实现回调函数在HAL_UART_RxCpltCallback这个文件, 这是便于初学者学习搞清楚代码结构, 但是更规范的做法是在 main.c 中实现, 以后的回调函数实现我也都会在 main.c 中
并且这个函数不要写在 main 函数里面, 应该写在 user code 4
这里比较合适 1
2
3
4
5
6
7
8
9
10
11
12
13
14/* USER CODE BEGIN 4 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart == &huart1)
{
HAL_UART_Receive_IT(&huart1, RxData, 5); // 再使能一次中断式接受
if (RxData[0] == '1')
{
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
RxData[0] = '0';
}
}
}
/* USER CODE END 4 */
我们也可以追根溯源一下这个回调函数

| 层次 | 函数 |
|---|---|
| 1 | USART1_IRQHandler |
| 2 | HAL_UART_IRQHandler |
| 3 | UART_Receive_IT |
| 4 | HAL_UART_RxCpltCallback |
这比我们之前学的外部中断和定时器中断多一层
第 3 步, 使能中断式发送
在 while(1)中我们启用中断式发送
1 | /* Infinite loop */ |
想想看, 这里我们要不要实现这个发送的回调函数?
回答是不用的, 中断式发送, 它的目的就是要发送数据, 它中断的是原本要用来等待外设搬运数据的这个时间, 所以你写成这样你就可以实现中断式发送了
而这个中断式发送的回调函数, 是用于发送完数据之后, 如果你还要执行一些逻辑的时候才去使用它
第 4 步, 烧录和测试
连上你的单片机和串口, 在 clion 中启用 OpenOCD 程序进行烧录.
完成之后, 你会发现, 你只有每输入五个 1(完整的 5 个字节的数据)之后, 中断式接收回调函数才会被启用, 去判断你的数据的第一个字节的值
然后你再观察, 你输入的时候, 单片机的发送仍然在继续着, 这就是接收中断的体现; 如果没有这个接收中断, 单片机就会一直在等待你发送完 5 个数据, 期间做不了任何事情 然后你再观察, 你很快的输入的时候, 单片机的发送速度不会改变, 与上面的非中断式形成鲜明的对比, 这就是发送中断的体现
完成这个实验之后, 你还可以吧HAL_UART_Receive_IT这个函数设成接受一字节就行, 那么就会变成, 按一下 1, 灯立马就会亮起来
如果, 你确实是要在发送完数据之后完成一些逻辑, 那么我们就可以使用发送中断函数HAL_UART_TxCpltCallback, 和我们之前的中断一样的用法, 写在 main.c 的 user code 4 中比较合适
但是它的调用层次又有些许的不同, 我们现在来进行探究 在中断向量层的 ISR
函数HAL_UART_IRQHandler
内我们可以看到有一个UART_EndTransmit_IT函数
我们继续进入它,
发现在这个函数中他才调用了我们的回调函数
所以调用层次是这样的:
| 层次 | 函数 |
|---|---|
| 1 | USART1_IRQHandler |
| 2 | HAL_UART_IRQHandler |
| 3 | UART_EndTransmit_IT |
| 4 | HAL_UART_TxCpltCallback |
这里也是四层, 但是和 Rx 不一样的是, 第三层 Tx 是 EndTransmit 函数, 而 Rx 是Receive函数, 没有 EndReceive. 而 Tx 的 Transmit 函数有自己的作用, 可以课外去了解
Step 2.3 - 空闲(IDLE)中断式
这个实验我们要使用空闲式中断接收函数来完成点灯, 因为发送没有空闲这个概念, 所以我们直接说是空闲中断函数
根据我们原理篇的讲解, 空闲式中断函数的逻辑是:
- 要么收满 5 个就回调
- 要么对方发完一阵、线路空闲下来了(哪怕只发了 1 个、3 个),也立刻回调
第 1 步 - CubeMX 的配置
和之前的配置一样, 我们需要使能 USART1 的中断
第 2 步 - 写 PV
和之前一样写 PV 1
2
3
4/* USER CODE BEGIN PV */
uint8_t RxData[10];
uint8_t TxData[10] = "hello";
/* USER CODE END PV */
第 3 步 - 写空闲中断(接受)函数
我们在main 函数里面, 非 while(1)的地方开启一次空闲中断HAL_UARTEx_ReceiveToIdle_IT 与我们之前讲的接受中断函数同理, 他也是一个函数只能接收一次, 剩下的在中断里面再开启就行
1 | /* USER CODE BEGIN 2 */ |
它的回调函数是HAL_UARTEx_RxEventCallback,
它在HAL_UART_IRQHandler直接被调用了,
相比前面的非空闲中断少了一层
| 层次 | 函数 |
|---|---|
| 1 | USART1_IRQHandler |
| 2 | HAL_UART_IRQHandler |
| 3 | HAL_UARTEx_RxEventCallback |
所以我们同样的, 在 user code 4 中实现这个空闲中断回调函数
1 | /* USER CODE BEGIN 4 */ |
其实在这个简单的实验里面清理首位就行, 甚至不清理下次传入的数据也会把之前的旧数据覆盖掉. 但是我这里使用 for 循环把所有的数据清理掉了是为了用上这个函数的 Size 参数, 这样显得更加优雅一点
第 4 步 - 写中断式发送函数
和之前一样, 写HAL_UART_Transmit_IT 函数
1 | /* Infinite loop */ |
第 5 步 - 烧录并测试
现在你可以试试看每次发送一个 1, 灯就会打开或者关闭一次
然后你再试一试一次发六个1 或者100001 灯会有什么变化 你会发现灯短暂的亮了一下(很小很小)然后又迅速被关闭了, 这就是空闲中断的体现, 前面的 5 个 1 已经满足一次中断, 然后后面的一个 1 自己单独触发一次空闲中断
如果你和我一样使用的是 CoolTerm 那么在上面的Option(设置)
你可以开启发送字符串的选项,
但是如果你发送单个字符就直接在输出的窗口点就好了
Step 2.4 - 普通 DMA 式
第 1 步: CubeMX 的配置
和之前的一样, 使能USART1 的中断, 完成相关的配置
接下来, 我们需要使能 USART1 的 DMA 点击 Add, 将 Rx 和 Tx
都加上
这里我将详细讲讲 DMA
中的具体的这些配置项都是什么意思
Mode, 普通模式即可, 他支持定时搬运或代码主动调用. 循环模式会一直搬运下去Data Width选择 Byte, 因为 UART 以字节区分数据Increment Address, 地址自增. 如果勾上 Peripheral(外设)意思就是:DMA 每搬一个数据,外设侧的访问地址也 +1,下一个数据就去 外设基址 + 1、再下一个 +2 对于 UART 来说它必须不能勾上, 因为UART 收发永远只用一个固定的寄存器 – 数据寄存器DR。发的时候所有字节都往同一个DR写,收的时候所有字节都从同一个DR读。这个DR的地址是固定的,字节是靠移位寄存器一个个从这同一个口进出的(这与前面的原理篇的内容相呼应)
而 Memory 需要勾上, 内存侧是你的缓冲区 RxData[10](或
TxData),你当然希望第 1 个字节进 RxData[0]、第
2 个进 RxData[1]、第 3 个进
RxData[2]……一格一格往后填
所以内存地址要自增 如果不勾上 Memory
你的内存地址不会自增, 所有的数据都堆在同一位,
这就导致了后来的数据会把先前的数据覆盖掉, 最终的你数据只剩下最后一个在
RxData[0] 的位置
- FIFO, First In First Out 先进先出, 就像队列一样(有些芯片会有这个选项) 当接受信息太多的时候 DMA 可能搬运不过来, FIFO可以先把来不及搬运的内容缓存下来排队, 再一个一个的让 DMA 进行搬运, 但代价是让信息的时效性降低
你需要考虑代价的对比, 信息丢失 vs 信息时效性差, 根据这个来决定是否启用 FIFO 和 FIFO 的长度, 在这个实验中我们不用进行勾选
现在你回头去看看原理篇结尾的内容吧!
第 2 步: 写代码
HAL_UART_Receive_DMA()
什么时候产生中断?
当调用 HAL_UART_Receive_DMA(&huart1, RxData, 10)
时,你设定了接收长度为 10
DMA 硬件只认数量,不认时间, 它只会在以下两个固定的数量节点产生中断:
- 过半中断(HT, Half Transfer):当收满一半数据(第 2 个字节, 向下取整)时触发
- 完成中断(TC, Transfer Complete):当不多不少正好收满 10 个字节时触发, 才此时会进入 HAL 库的回调函数 **__weak void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart){}**
如果对方只发了 5 个字节就停止了,因为没有收满 10
个字节,DMA
的完成中断永远不会触发,HAL_UART_RxCpltCallback
也绝对不会执行, 数据就会卡在缓冲区里,这就是为什么不能单靠
HAL_UART_Receive_DMA() 来接收不定长数据
总体代码如下:
1 | /* USER CODE BEGIN PV */ |
这个使用 DMA 的中断函数和我们之前那个Step 2.2 - 中断式 的回调函数是一样的
现象: 你发现他和 Step 2.2 的实验现象没什么两样, 也确实, 因为他本质就是用 DMA 代劳了数据的搬运, 只会在长任务体现出效率的差别, 这个小实验看不出啥
Step 2.5 - 空闲 DMA 式
1 | /* USER CODE BEGIN PV */ |
总结一下:
1 | /*阻塞收发函数*/ |
1 | /*中断收发函数*/ |
1 | /* DMA 中断 - 非阻塞*/ |
我们发现, 只有空闲阻塞或者空闲中断的回调函数发生了改变, 而其他的都是void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart){}
因为空闲是 UARTEx事件, 所以我们要使用
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart){}回调函数,
我们还可以通过如下函数查询是什么事件触发了UARTEx回调函数
1 | // 查询是什么事件触发了UARTEx回调函数 |

配置好 USART1 这个外设之后你就会发现右侧的芯片图多使能了两个 GPIO口,
这个就是我们 daplink 要连接的地方
与此同时我们要注意C 语言字符串在末尾会自动加上 \0 表示终止,
这个内存试图没有看出来我也不知道是怎么回事, 但是这个 \0 也就是在下标 7
的位置他是真正存在的
这里表示从 uart1 发出, 发送的数据是 TxData, 发送 7 个字节(7 字节就行,
因为后面的都是我们不需要的, 你也可以看看把它填成 8 会出现什么情况,
是不是会多一些点出来), 最长等待时间 1000 ms











