PY32F403 USART — 轮询方式串口收发与回显

PUYA PY32F403 HAL 库 V1.4.5 USART_HyperTerminal_Polling 示例逐行讲解:波特率、超时机制、回显、printf 重定向

USART_HyperTerminal_Polling — 串口轮询收发与回显

芯片: PY32F403(Cortex-M4,最高 72MHz) 固件库: PY32F403_Firmware V1.4.5 源文件: PY32F403_Firmware_V1.4.5/Projects/PY32F403-STK/Example/USART/USART_HyperTerminal_Polling/Src/main.c 难度: ★☆☆☆☆ 入门必做(第二课)


1. 功能简介

程序流程用大白话说就是四步:

1
2
3
4
① 上电 → 串口发一句提示:"请用键盘输入 12 个字符"
② 死等 → 一直等,直到收满 12 个字节(最多等 5 秒)
③ 回显 → 把收到的 12 个字节原封不动发回去
④ 收尾 → 发一句 "Example Finished",点亮 LED,进死循环

“轮询”(Polling)是什么意思?

就是 CPU 站在那里死等HAL_UART_Receive() 内部是这样的:

1
2
3
4
5
6
7
循环 {
   看一眼状态寄存器的 RXNE 标志位(接收缓冲区非空?)
   还没来 → 再看一眼 → 再看一眼 → ...(CPU 就在这空转)
   来了   → 从 DR 寄存器读走这个字节,计数 -1
   计数到 0 → 返回 HAL_OK
   超时了   → 返回 HAL_TIMEOUT
}

优点是逻辑直白,一眼就懂;缺点是这 5 秒里 CPU 干不了别的事。所以轮询适合:初始化打印、长度已知的短数据、以及现在这种教学场景。

2. 硬件准备

需要的东西 说明
PY32F403-STK 开发板
USB-TTL 模块 CH340 / CP2102 / FT232 任选,注意选 3.3V 电平档
3 根杜邦线 TX / RX / GND
串口助手软件 SSCOM、XCOM、MobaXterm、PuTTY、VSCode 串口插件都行

接线(关键!TX 接 RX,交叉接)

1
2
3
4
5
   PY32F403-STK              USB-TTL 模块
   ─────────────             ─────────────
   PA2  (USART2_TX)  ──────►  RX
   PA3  (USART2_RX)  ◄──────  TX
   GND               ───────  GND     ★ 必须接,否则没有共同参考地,全是乱码

串口助手参数

1
2
3
4
5
波特率  : 115200
数据位  : 8
停止位  : 1
校验位  : None
流  控  : None

为什么是 PA2/PA3? 这是 STK 板 BSP 里写死的调试口定义(py32f403xx_Start_Kit.h 中的 DEBUG_USART),复用编号是 GPIO_AF2_USART2。如果你自己画板子换了引脚,改 MSP 里的 GPIO 配置即可。

3. 完整代码(带中文注释)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
/* ==========================================================================
 * PY32F403 USART_HyperTerminal_Polling
 *   USART2 (PA2/PA3) @115200,轮询发送 → 轮询接收 12 字节 → 原样回显
 * ========================================================================== */
#include "main.h"

/* 求数组元素个数的经典宏 */
#define COUNTOF(__BUFFER__)   (sizeof(__BUFFER__) / sizeof(*(__BUFFER__)))
/* -1 是为了去掉字符串字面量末尾的 '\0',我们不想把结束符也发出去 */
#define TXSTARTMESSAGESIZE    (COUNTOF(aTxStartMessage) - 1)
#define TXENDMESSAGESIZE      (COUNTOF(aTxEndMessage)   - 1)

UART_HandleTypeDef UartHandle;      /* 串口句柄:HAL 用它记录配置和状态 */

/* 开场提示语。\r\n 是回车+换行,Windows 串口助手认这个组合 */
uint8_t aTxStartMessage[] = "\r\n UART Hyperterminal communication based on Polling"
                            "\r\n Enter 12 characters using keyboard :\r\n";
uint8_t aTxEndMessage[]   = "\r\n Example Finished\r\n";

/* 接收缓冲区,预填 0xFF 方便调试时一眼看出"哪些位置真的收到了数据" */
uint8_t aRxBuffer[12] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
                         0xff, 0xff, 0xff, 0xff, 0xff, 0xff};

static void APP_SystemClockConfig(void);

int main(void)
{
  /* HAL 初始化:配 Flash + 启动 SysTick(超时计时靠它) */
  HAL_Init();

  /* LED 初始化:最后用它表示"全流程成功" */
  BSP_LED_Init(LED_GREEN);

  /* 系统时钟:HSI 8MHz(PCLK1 = 8MHz,供 USART2 用) */
  APP_SystemClockConfig();

  /* ---------------- 串口初始化 ---------------- */
  UartHandle.Instance          = USART2;                  /* 用 USART2(STK 调试口)*/
  UartHandle.Init.BaudRate     = 115200;                  /* 波特率                */
  UartHandle.Init.WordLength   = UART_WORDLENGTH_8B;      /* 8 位数据              */
  UartHandle.Init.StopBits     = UART_STOPBITS_1;         /* 1 位停止              */
  UartHandle.Init.Parity       = UART_PARITY_NONE;        /* 无校验                */
  UartHandle.Init.HwFlowCtl    = UART_HWCONTROL_NONE;     /* 不用 RTS/CTS 流控     */
  UartHandle.Init.Mode         = UART_MODE_TX_RX;         /* 收发都要(全双工)    */
  UartHandle.Init.OverSampling = UART_OVERSAMPLING_16;    /* 16 倍过采样,抗噪更好 */
  /* 高级功能(自动波特率、引脚交换、数据取反等)本例都不用 */
  UartHandle.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_NO_INIT;

  /* 这一句内部会自动回调 HAL_UART_MspInit(),在那里配 PA2/PA3 的 GPIO 复用 */
  HAL_UART_Init(&UartHandle);

  /* ---------------- ① 发送开场提示 ---------------- */
  /* 参数:句柄、数据指针、字节数、超时(ms)
   * 返回 HAL_OK 表示全部字节都已送进移位寄存器 */
  if (HAL_UART_Transmit(&UartHandle, (uint8_t *)aTxStartMessage,
                        TXSTARTMESSAGESIZE, 5000) != HAL_OK)
  {
    APP_ErrorHandler();
  }

  /* ---------------- ② 轮询接收 12 字节 ---------------- */
  /* ★ 注意:这里会阻塞!收不满 12 字节就一直等,5 秒后超时返回 HAL_TIMEOUT
   *   → 所以你必须在 5 秒内在串口助手里发够 12 个字符 */
  if (HAL_UART_Receive(&UartHandle, (uint8_t *)aRxBuffer, 12, 5000) != HAL_OK)
  {
    APP_ErrorHandler();
  }

  /* ---------------- ③ 原样回显 ---------------- */
  if (HAL_UART_Transmit(&UartHandle, (uint8_t *)aRxBuffer, 12, 5000) != HAL_OK)
  {
    APP_ErrorHandler();
  }

  /* ---------------- ④ 发送结束语 ---------------- */
  if (HAL_UART_Transmit(&UartHandle, (uint8_t *)aTxEndMessage,
                        TXENDMESSAGESIZE, 5000) != HAL_OK)
  {
    APP_ErrorHandler();
  }

  /* 全流程无错 → LED 常亮,作为"成功"的可视化指示 */
  BSP_LED_On(LED_GREEN);

  while (1)
  {
  }
}

/**
  * @brief  系统时钟配置:HSI 8MHz
  *         HCLK = 8MHz,PCLK1 = 8MHz(USART2 挂在 APB1),PCLK2 = 4MHz
  */
static void APP_SystemClockConfig(void)
{
  RCC_OscInitTypeDef  OscInitstruct = {0};
  RCC_ClkInitTypeDef  ClkInitstruct = {0};

  OscInitstruct.OscillatorType  = RCC_OSCILLATORTYPE_HSE | RCC_OSCILLATORTYPE_HSI
                                | RCC_OSCILLATORTYPE_LSE | RCC_OSCILLATORTYPE_LSI
                                | RCC_OSCILLATORTYPE_HSI48M;
  OscInitstruct.HSEState        = RCC_HSE_OFF;
  OscInitstruct.HSI48MState     = RCC_HSI48M_OFF;
  OscInitstruct.HSIState        = RCC_HSI_ON;       /* 只用内部 8MHz RC */
  OscInitstruct.LSEState        = RCC_LSE_OFF;
  OscInitstruct.LSIState        = RCC_LSI_OFF;
  OscInitstruct.PLL.PLLState    = RCC_PLL_OFF;
  if (HAL_RCC_OscConfig(&OscInitstruct) != HAL_OK)
  {
    APP_ErrorHandler();
  }

  ClkInitstruct.ClockType      = RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_HCLK
                               | RCC_CLOCKTYPE_PCLK1  | RCC_CLOCKTYPE_PCLK2;
  ClkInitstruct.SYSCLKSource   = RCC_SYSCLKSOURCE_HSI;
  ClkInitstruct.AHBCLKDivider  = RCC_SYSCLK_DIV1;   /* HCLK  = 8MHz */
  ClkInitstruct.APB1CLKDivider = RCC_HCLK_DIV1;     /* PCLK1 = 8MHz ← USART2 的时钟源 */
  ClkInitstruct.APB2CLKDivider = RCC_HCLK_DIV2;     /* PCLK2 = 4MHz */
  if (HAL_RCC_ClockConfig(&ClkInitstruct, FLASH_LATENCY_0) != HAL_OK)
  {
    APP_ErrorHandler();
  }
}

void APP_ErrorHandler(void)
{
  while (1)
  {
  }
}

配套的 MSP 文件(引脚在这里配)

HAL_UART_Init() 内部会自动调用下面这个弱函数,GPIO 复用就在这里做:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
void HAL_UART_MspInit(UART_HandleTypeDef *huart)
{
  GPIO_InitTypeDef GPIO_InitStruct = {0};

  __HAL_RCC_USART2_CLK_ENABLE();      /* USART2 时钟(APB1) */
  __HAL_RCC_GPIOA_CLK_ENABLE();       /* GPIOA 时钟(AHB2)  */

  /* PA2 → USART2_TX:复用推挽输出 */
  GPIO_InitStruct.Pin       = GPIO_PIN_2;
  GPIO_InitStruct.Mode      = GPIO_MODE_AF_PP;
  GPIO_InitStruct.Pull      = GPIO_PULLUP;
  GPIO_InitStruct.Speed     = GPIO_SPEED_FREQ_VERY_HIGH;
  GPIO_InitStruct.Alternate = GPIO_AF2_USART2;      /* ★ AF 编号必须对 */
  HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

  /* PA3 → USART2_RX */
  GPIO_InitStruct.Pin       = GPIO_PIN_3;
  GPIO_InitStruct.Alternate = GPIO_AF2_USART2;
  HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
}

4. 代码分段讲解

4.1 TXSTARTMESSAGESIZE 为什么要 -1

1
uint8_t s[] = "AB";   /* 内存里实际是:'A' 'B' '\0',sizeof(s) == 3 */

C 语言的字符串字面量末尾自动带一个 '\0'。这个 '\0' 是给 C 库函数(strlen 等)用的标记,不是要发送的数据。如果不减 1,串口助手就会多收到一个 0x00 字节,在 HEX 模式下能明显看到。

4.2 Init 结构体每个字段的含义

字段 大白话
Instance USART2 用哪个串口硬件
BaudRate 115200 每秒传多少位。双方必须一致
WordLength 8B 一帧里有几位有效数据。8 位是绝对主流
StopBits 1 帧尾停多久。1 位够用,2 位更保险但慢一点
Parity NONE 不要校验位。要校验就选 EVEN/ODD,但那样 8 位数据里有 1 位被占,实际数据只有 7 位
HwFlowCtl NONE 不用 RTS/CTS 硬件握手。只有高速大数据量才需要
Mode TX_RX 既能发也能收。只发可以填 UART_MODE_TX 省一个引脚
OverSampling 16 一个位周期采样 16 次取中间值。抗噪好但要求时钟余量大;8 倍能跑更高波特率但更容易错

4.3 一帧数据在线上长什么样

以发送字符 'A'(ASCII = 0x41 = 二进制 0100 0001)为例,8-N-1 格式:

1
2
3
4
5
空闲(高) │起始│ D0  D1  D2  D3  D4  D5  D6  D7 │停止│ 空闲(高)
─────────┐    ┌───┐           ┌───────┐        ┌──────────────
         └────┘   └───────────┘       └────────┘
          低    1   0   0   0   0   0   1   0     高
              └─ LSB 先发!0x41 的二进制反过来读 ─┘

关键点:串口是 LSB(最低位)先发。这就是为什么用逻辑分析仪抓波形时,肉眼读出来的位序要反过来才是真实数值。

一帧共 10 位(1 起始 + 8 数据 + 1 停止),所以:

1
2
115200 波特率的实际吞吐 = 115200 / 10 = 11520 字节/秒 ≈ 11.25 KB/s
发 12 个字节需要 12 / 11520 ≈ 1.04 ms

4.4 超时参数怎么理解

1
HAL_UART_Receive(&UartHandle, aRxBuffer, 12, 5000);

这个 5000 不是"每个字节等 5 秒",而是整个接收过程的总预算。HAL 内部:

1
2
3
4
5
6
tickstart = HAL_GetTick();
while (还没收满 12 字节) {
   if (HAL_GetTick() - tickstart > 5000) return HAL_TIMEOUT;
   if (RXNE 置位) { 读一个字节; 计数--; }
}
return HAL_OK;

如果你想"永远等下去",传 HAL_MAX_DELAY(即 0xFFFFFFFF)。

超时机制依赖 SysTick,所以还是那句话:HAL_Init() 不能少。

4.5 轮询的本质缺陷

这段代码在等 12 个字节的时候,CPU 什么都干不了。假如你的项目还要同时:

  • 每 10ms 采一次 ADC
  • 每 100ms 刷一次屏
  • 实时响应急停按钮

那轮询就完全不能用了。解决办法:

方案 做法 适用
中断接收 HAL_UART_Receive_IT(),收到就进 HAL_UART_RxCpltCallback() 通用首选
DMA 接收 HAL_UART_Receive_DMA(),硬件搬到内存 大数据量
DMA + IDLE DMA 循环接收 + 空闲中断判定帧结束 不定长数据的最佳方案

同目录下的 USART_HyperTerminal_ITUSART_HyperTerminal_DMA 就是前两种的示范。

4.6 把 printf 打通(强烈建议做这一步)

本例是手动调 HAL_UART_Transmit(),实际开发中大家都用 printf。改法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>

int main(void)
{
  HAL_Init();
  APP_SystemClockConfig();
  BSP_USART_Config();                /* BSP 帮你初始化 USART2 + 重定向 */

  printf("Hello PY32F403!\r\n");
  printf("HCLK  = %lu Hz\r\n", HAL_RCC_GetHCLKFreq());
  printf("PCLK1 = %lu Hz\r\n", HAL_RCC_GetPCLK1Freq());

  int v = 1234;
  float f = 3.14159f;
  printf("int=%d  float=%.2f  hex=0x%08X\r\n", v, f, v);

  while (1) {}
}

三个坑

  1. MDK 必须勾 Use MicroLIB(Options → Target),否则卡在半主机陷阱里,表现为下载后毫无反应。
  2. 要打印 float,GCC 环境需要在链接选项加 -u _printf_float,否则浮点数打出来是空的。
  3. printf阻塞的,而且很吃 Flash(几 KB)和栈。在中断服务函数里调 printf 是大忌,容易破坏实时性甚至栈溢出。

5. 实验现象

  1. 编译下载,接好 USB-TTL,打开串口助手(115200-8-N-1);
  2. 按复位键,串口助手立刻显示:
1
2
 UART Hyperterminal communication based on Polling
 Enter 12 characters using keyboard :
  1. 在 5 秒内在串口助手输入框里发送 12 个字符,比如 Hello World!(正好 12 个);
  2. 串口助手随即收到回显和结束语:
1
2
Hello World!
 Example Finished
  1. 开发板上的绿色 LED 常亮,表示全流程成功。

时序示意

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
上电
 ├─► MCU 发送提示语(约 90 字节,8ms)
 ├─► 等待 ...(最多 5 秒,CPU 空转)
 │        ↑ 你在这里输入 12 个字符
 ├─► 收满 12 字节
 ├─► 回显 12 字节
 ├─► 发送 " Example Finished"
 └─► LED 亮 → while(1) 死循环

6. 常见问题

Q1:串口助手一片乱码(全是问号或方块)?

按概率从高到低排查:

  1. 波特率不对。串口助手改成 115200 试试。
  2. GND 没接。这是第二常见原因,一定要共地。
  3. 时钟配置和代码不符。如果你改了 APP_SystemClockConfig() 但忘了重新算,波特率会整体偏移。用 printf("%lu\r\n", HAL_RCC_GetPCLK1Freq()); 验证 PCLK1 是不是 8MHz。
  4. HSI 温漂。内部 RC 精度约 ±1~2%,在极端温度下叠加分频误差可能超标。改用 HSE 晶振可解决。
  5. TTL 模块是 5V 电平。虽然多数情况下 F403 能容忍,但边沿会变形,长期还可能损伤 IO。务必用 3.3V 档。

Q2:完全收不到任何数据?

  1. TX/RX 接反了。这是第一名。板子的 TX(PA2) 要接模块的 RX,不是 TX 接 TX。
  2. 串口助手选错 COM 口。设备管理器里确认 CH340 的端口号。
  3. 另一个软件占用了串口。关掉 MobaXterm / 之前开的窗口再试。
  4. 忘了 BSP_USART_Config()HAL_UART_Init()
  5. USART2 时钟没开__HAL_RCC_USART2_CLK_ENABLE())。
  6. AF 编号写错。PA2/PA3 做 USART2 必须是 GPIO_AF2_USART2,写成别的编号引脚就不通。

Q3:能收到提示语,但输入字符后没有回显?

  • 发的字符数不够 12 个HAL_UART_Receive(..., 12, 5000) 一定要收满 12 个才返回。串口助手里注意"发送新行"选项会不会多发 \r\n(那就变成 14 个了,前 12 个先返回,剩下 2 个留在缓冲区)。
  • 超过 5 秒才输入。已经超时进 APP_ErrorHandler() 死循环了,复位重来。
  • RX 引脚没通。用镊子把 PA3 短接到 GND 再松开,若程序有反应说明引脚是活的。

Q4:HAL_UART_Receive() 返回 HAL_ERROR,不是 TIMEOUT?

说明产生了硬件错误标志,读 UartHandle.ErrorCode 看具体是哪个:

错误 含义 原因
HAL_UART_ERROR_ORE 溢出错误 上一个字节还没读走,新字节又来了。轮询模式下最常见 → 改用中断/DMA
HAL_UART_ERROR_NE 噪声错误 线路干扰、线太长、波特率偏差大
HAL_UART_ERROR_FE 帧错误 没检测到停止位。通常是波特率差太多
HAL_UART_ERROR_PE 校验错误 双方 Parity 设置不一致

出现 ORE 后必须清标志才能继续收:

1
__HAL_UART_CLEAR_OREFLAG(&UartHandle);

Q5:printf 中文乱码?

串口助手的编码要和源文件编码一致。源文件存成 UTF-8,串口助手也选 UTF-8;或者两边都用 GBK。MDK 默认可能是 GB2312,注意统一。

Q6:为什么要用 \r\n 而不是只用 \n

  • \n(LF, 0x0A):换到下一行,但光标横向位置不变
  • \r(CR, 0x0D):光标回到行首

很多串口终端(尤其 Windows 系)只发 \n 会出现"阶梯状"输出:

1
2
3
第一行
      第二行
            第三行

所以串口输出统一用 \r\n,这是行业惯例。

7. 小结

要点 内容
串口本质 TX/RX 两根线,全双工,LSB 先发,必须共地
帧格式 1 起始 + 8 数据 + 1 停止 = 10 位/字节
实际吞吐 波特率 ÷ 10 ≈ 字节/秒(115200 → 约 11.5 KB/s)
初始化流程 Init 结构体 → HAL_UART_Init() → MSP 里自动配 GPIO 复用
轮询三件套 HAL_UART_Transmit() / HAL_UART_Receive() + 超时参数
超时含义 整个操作的总预算,不是单字节
字符串长度 记得 -1 去掉 '\0'
STK 调试口 USART2,PA2=TX / PA3=RX,AF2,115200-8-N-1
乱码三大元凶 波特率不符、没共地、时钟配置与代码不一致
收不到三大元凶 TX/RX 没交叉、COM 口选错、AF 编号写错

8. 拓展应用

立刻能做的

  • 把 printf 打通,这是投资回报率最高的一步。之后调 ADC、SPI、I2C 全都靠它看数据。
  • 串口命令行(Shell):接收字符串,strcmp 比对命令,实现 led on / led off / adc? / reboot 等交互命令。这是一个非常好玩且实用的练手项目。
  • 数据记录仪:每秒把 ADC 温度 打印成 CSV 格式(时间,温度\n),串口助手保存成文件,直接拖进 Excel 画曲线。

进阶项目

  • 自定义协议帧0xAA 0x55 | 长度 | 命令 | 数据 | CRC16 | 0x0D 0x0A,配合状态机解析。这是所有产品级串口通信的基础结构。
  • Modbus RTU:加一片 MAX485 变成 RS485,实现标准工业协议,能直接接组态软件、PLC、工业仪表。RS485 是半双工,需要一个 GPIO 控制收发方向。
  • AT 指令模块驱动:ESP8266(WiFi)、SIM800(GSM)、HC-05(蓝牙)都是"发 AT 指令 + 解析 OK/ERROR"的模式。做一个"发送命令 → 等待期望响应 → 超时重试"的通用框架。
  • NMEA 解析:GPS 模块吐 $GPRMC,... 这样的 ASCII 语句,按逗号切分就能拿到经纬度、速度、时间。

工程级

  • 串口 IAP 在线升级(Bootloader):这是嵌入式的"必修高级课"。流程是:Bootloader 区(0x08000000)启动 → 检查升级标志 → 从串口收固件 → 用 Flash 擦写 写进 App 区 → 跳转执行。F403 有 384KB Flash(xD 型号),完全够放 Bootloader + 双备份 App。
  • DMA + IDLE 空闲中断接收框架:解决"不知道对方发多长"的终极方案。开一个环形缓冲区 DMA 循环接收,串口空闲一帧时间就触发 IDLE 中断,此时读 DMA 剩余计数就知道收了多少字节。零 CPU 占用,能跑到几 Mbps。
  • 多机通信:RS485 总线上挂 32 个从机,用地址位/9 位数据模式做寻址,实现主从轮询采集。
  • 日志分级系统:仿照嵌入式 RTOS 的做法,定义 LOG_D/LOG_I/LOG_W/LOG_E 宏,带颜色转义码(ANSI Escape)输出到支持彩色的终端,可编译期裁剪日志等级。

下一步建议

串口通了之后,去看 ADC_TempSensor —— 那个例子就是靠 printf 把芯片温度打出来的,正好检验你的串口成果。

世界是你们
使用 Hugo 构建
主题 StackJimmy 设计