用GPIO模拟UART通信
用GPIO模拟UART通信
前言
学习STM32的底层通信协议,那串口肯定是必不可少,与其直接用硬件USART,不如自己用GPIO模拟一个串口出来,顺便接个ST7735小屏幕把通信状态显示出来,做完这个实验也可以加深对串口通信的了解

硬件准备
材料清单
| 器件 | 数量 | 说明 |
|---|---|---|
| STM32F103C8T6 最小系统板 | 1 | |
| ST7735 1.44寸 TFT屏 | 1 | SPI接口,128×160分辨率 |
| CH340 USB转TTL模块 | 1 | 串口USB |
| 杜邦线 | 若干 | 母对母 |
接线
ST7735 用 SPI1,软件串口用 PB6 做 TX、PB7 做 RX。具体接线:
ST7735 屏幕:
SCK → PA5
SDA → PA7
CS → PA4
DC → PB0
RST → PB1
VCC → 3.3V
GND → GND
CH340 模块:
TXD → PB7 (STM32的RX)
RXD → PB6 (STM32的TX)
GND → GND
CH340的TXD接STM32的RX,RXD接TX,交叉连接。模块上的3.3V/5V跳帽一定要选3.3V,不然可能烧IO口。
软件UART的原理
UART帧格式
一个标准的UART数据帧包含 1 个起始位 + 8 个数据位 + 1 个停止位,总共 10 个位。空闲时线路保持高电平,起始位是一个低电平脉冲。
起始位 D0 D1 D2 D3 D4 D5 D6 D7 停止位
┌─┐ ┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ └───┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘
│ 起始位 停止位│
└───────────────────────────────────────────────────────┘
一个UART数据帧 = 10 bits
9600 波特率下,每个位的持续时间是:
1 / 9600 ≈ 104.17 µs
也就是说,不管是发还是收,每隔 104 微秒就得处理一个位。
倍率过采样
这是整个项目较为关键的部分,一开始我想用 EXTI 外部中断来抓起始位的下降沿,这样定时器只跑 9600Hz 就够了。但后来想了想,中断还是比较麻烦,不如全交给定时器
定时器跑 28800Hz,正好是 9600 的 3 倍。
为啥是 3 倍?
因为定时器不知道起始位的下降沿到底发生在哪个时刻,它只能在自己的中断里"看到"电平变了。最坏情况下,定时器发现起始位的时候,起始位已经开始了将近一个定时器周期(约 34.7µs)。
用 3 倍频率,每个位被分成 3 个节拍:
一个位(104µs)
├─────────────┤
↑ ↑ ↑
1 2 3 <-- 3个采样节拍
↑
在第3拍采样
(位的中心附近)
在接近位中心的位置采样,即使起始位晚发现了一点,采样点仍然落在位的安全区域内。相比 1 倍采样,3 倍过采样的抗干扰能力和时序容差都好很多。
TIM3 的参数计算
STM32F103 主频 72MHz,目标中断频率 28800Hz:
72,000,000 ÷ (Prescaler + 1) ÷ (ARR + 1) = 28,800
72,000,000 ÷ 250 ÷ 10 = 28,800 Hz
↑ ↑
PSC=249 ARR=9
每次中断间隔约 34.7µs,正好是 3 倍波特率的节奏。
STM32CubeMX 配置
新建工程,选 STM32F103C8Tx,主要配置以下几点:
System Core:
- SYS → Debug: Serial Wire(释放 PB3/PB4/PA15)
- RCC → HSE: Crystal/Ceramic Resonator
SPI1(驱动ST7735):
| 参数 | 值 |
|---|---|
| Mode | Full-Duplex Master |
| Prescaler | 128(72M/128 ≈ 562kbps) |
| CPOL | Low |
| CPHA | 1 Edge |
| NSS | Disable(软件控制CS) |
CubeMX 会自动分配 PA5=SCK、PA6=MISO、PA7=MOSI。
GPIO:
| 引脚 | 模式 | 用途 |
|---|---|---|
| PA4 | Output | ST7735 CS |
| PB0 | Output | ST7735 DC |
| PB1 | Output | ST7735 RST |
| PB6 | Output | 软串口 TX |
| PB7 | Input + Pull-up | 软串口 RX |

TIM3:
| 参数 | 值 |
|---|---|
| Prescaler | 249 |
| Counter Period | 9 |
| NVIC | 开启 TIM3 global interrupt |

时钟树:
HSE(8MHz) → PLL×9 → SYSCLK 72MHz

配好后 GENERATE CODE,选 STM32CubeIDE 工具链。
核心代码
工程文件结构:
Core/
├── Inc/
│ ├── st7735.h # 屏幕驱动头文件
│ ├── fonts.h # 字库定义
│ ├── sw_uart.h # 软串口头文件
│ └── ...
└── Src/
├── st7735.c # 屏幕驱动
├── fonts.c # 字库数据
├── sw_uart.c # 软串口核心代码
├── stm32f1xx_it.c # 中断服务函数
└── main.c # 主程序
sw_uart.c —— 软串口实现
软串口的全部逻辑都在 TIM3 的中断服务里跑,由 SW_UART_TIM_IRQHandler() 统一调度。它每 34.7µs 被调用一次,同时处理 TX 发送和 RX 接收两个状态机。
TX 发送
每 3 个 tick 输出一个位。tick 0-2 是起始位(拉低),tick 3-26 依次输出 8 个数据位,tick 27-29 是停止位(拉高):
if (tx_state == TX_SENDING) {
uint8_t bit_index = tx_tick / 3;
if (bit_index == 0) {
HAL_GPIO_WritePin(TX_PORT, TX_PIN, GPIO_PIN_RESET); // 起始位
} else if (bit_index <= 8) {
uint8_t val = (tx_data >> (bit_index - 1)) & 0x01;
HAL_GPIO_WritePin(TX_PORT, TX_PIN,
val ? GPIO_PIN_SET : GPIO_PIN_RESET); // 数据位
} else {
HAL_GPIO_WritePin(TX_PORT, TX_PIN, GPIO_PIN_SET); // 停止位
}
tx_tick++;
if (tx_tick >= 30) {
tx_state = TX_IDLE;
tx_busy = 0; // 发送完成,释放标志位
}
}
上层调用 SW_UART_SendByte() 时只是把数据写进 tx_data 然后置忙,真正的发送由 ISR 在后台完成。while (tx_busy) 的忙等确保上一字节发完才写下一字节。
RX 接收
接收比发送复杂一些,需要自己找起始位。空闲态下一直盯着 PB7,一旦为低,说明有起始位来了:
if (rx_state == RX_IDLE) {
if (HAL_GPIO_ReadPin(RX_PORT, RX_PIN) == GPIO_PIN_RESET) {
rx_state = RX_RECEIVING;
rx_tick = 0;
rx_shift = 0;
}
}
进入接收态后,在 tick=4、7、10…25 这些位置采样数据位。每个采样点正好位于对应数据位的中心附近:
uint8_t t = rx_tick;
if (t == 4 || t == 7 || t == 10 || t == 13 ||
t == 16 || t == 19 || t == 22 || t == 25) {
uint8_t bit_index = (t - 1) / 3; // 计算是第几个数据位
if (HAL_GPIO_ReadPin(RX_PORT, RX_PIN)) {
rx_shift |= (1 << (bit_index - 1)); // 置位
}
}
tick=28 时验证停止位,如果停止位是低电平说明帧出错,直接丢弃:
if (t == 28) {
if (HAL_GPIO_ReadPin(RX_PORT, RX_PIN) == GPIO_PIN_RESET) {
rx_state = RX_IDLE; // 帧错误,丢弃
return;
}
}
连续字节的处理是我踩过的一个坑。 一开始收到字节后就切回 IDLE,等下个 ISR 再查起始位。但如果 PC 连续发送(串口助手一次性发 “hello” 五个字节),第 2 个字节的起始位紧接着第 1 个字节的停止位。等我的 ISR 反应过来,起始位已经跑掉将近 70µs 了,后面的采样点全偏了——结果就是第一个字节正确,后面的全是乱码。
修复方式:收完一个字节后立刻再查一次 PB7:
if (rx_tick >= 30) {
// 存字节到环形缓冲区...
// 立刻检查有没有下一个起始位(连续字节的情况)
if (HAL_GPIO_ReadPin(RX_PORT, RX_PIN) == GPIO_PIN_RESET) {
rx_state = RX_RECEIVING; // 不经过IDLE,直接继续接收
rx_tick = 0;
rx_shift = 0;
} else {
rx_state = RX_IDLE;
}
}
这样一来连续字节之间的转换就没有延迟了。
环形缓冲区
收到的字节存入一个 64 字节的环形缓冲区,ISR 写、主循环读,互不干扰:
uint8_t SW_UART_Available(void)
{
return (sw_uart_rx_head - sw_uart_rx_tail) & BUF_MASK;
}
uint8_t SW_UART_ReadByte(void)
{
uint8_t data = sw_uart_rx_buf[sw_uart_rx_tail];
sw_uart_rx_tail = (sw_uart_rx_tail + 1) & BUF_MASK;
return data;
}
main.c —— 主程序与显示
主循环逻辑很简单,轮询有没有收到字节,有就读出来然后原样发回去(回显),同时更新屏幕:
while (1) {
if (SW_UART_Available()) {
last_rx = SW_UART_ReadByte();
rx_total++;
SW_UART_SendByte(last_rx); // 回显
tx_total++;
// 超过400ms没收新数据就清空文本缓冲
if (HAL_GetTick() - last_rx_tick > 400) {
txt_len = 0;
}
last_rx_tick = HAL_GetTick();
// 可打印字符追加到显示缓冲
if (last_rx >= 32 && last_rx <= 126) {
if (txt_len < TXT_BUF_LEN) {
txt_buf[txt_len++] = (char)last_rx;
} else {
// 缓冲满了,左边滚出
memmove(txt_buf, txt_buf + 1, TXT_BUF_LEN - 1);
txt_buf[TXT_BUF_LEN - 1] = (char)last_rx;
}
}
UpdateDisplay();
}
}
屏幕刷新用了固定宽度的空格填充来覆盖旧内容,避免了大面积 FillRectangle 擦除带来的 SPI 延迟:
static void UpdateDisplay(void)
{
char buf[24];
uint8_t i, n;
// 文本第1行:18个字符,空格填充
n = (txt_len > TXT_LINE_LEN) ? TXT_LINE_LEN : txt_len;
for (i = 0; i < n; i++) buf[i] = txt_buf[i];
for (; i < TXT_LINE_LEN; i++) buf[i] = ' ';
buf[TXT_LINE_LEN] = '\0';
ST7735_WriteString(2, 48, buf, Font_7x10, TXT_GREEN, BG_COLOR);
// 计数器:定长输出,自然覆盖旧值
sprintf(buf, "RX:%05u", rx_total);
ST7735_WriteString(2, 75, buf, Font_7x10, TXT_DARK, BG_COLOR);
sprintf(buf, "TX:%05u", tx_total);
ST7735_WriteString(68, 75, buf, Font_7x10, TXT_TEAL, BG_COLOR);
}
stm32f1xx_it.c —— 中断入口
TIM3 中断里只做一件事,调用软串口的处理函数:
void TIM3_IRQHandler(void)
{
SW_UART_TIM_IRQHandler();
HAL_TIM_IRQHandler(&htim3);
}
显示屏界面

测试
上电后串口助手会收到 UART Ready。

随便发几个字符,屏幕上的 Text: 下面会实时显示收到的内容,同时原样回传给 PC。收发计数器也会同步更新。

发 “Test111”,屏幕显示 “Test111”,串口助手也能收到回显的 “Test111”。超过 400ms 不发新数据,屏幕文本自动清空,方便下次输入。
总结
这个实验有两个关键点:
- 三倍过采样是用纯定时器实现软串口的基础,兼顾了时序精度和配置简洁
- 连续字节的起始位检测容易被忽略,必须在一个 ISR 内完成"收尾—检测"的闭环
代码量不大,核心的软串口就一百多行,但它跑通了 UART 的全部底层逻辑。把这个搞明白了,再回头看 STM32 的 USART 硬件外设,很多寄存器的设计意图就一目了然了。
参考
- STM32F103C8T6 数据手册
- ST7735 数据手册
- Adafruit ST7735 Library
更多推荐



所有评论(0)