前言

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

在这里插入图片描述

硬件准备

材料清单

器件数量说明
STM32F103C8T6 最小系统板1
ST7735 1.44寸 TFT屏1SPI接口,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):

参数
ModeFull-Duplex Master
Prescaler128(72M/128 ≈ 562kbps)
CPOLLow
CPHA1 Edge
NSSDisable(软件控制CS)

CubeMX 会自动分配 PA5=SCK、PA6=MISO、PA7=MOSI。

GPIO:

引脚模式用途
PA4OutputST7735 CS
PB0OutputST7735 DC
PB1OutputST7735 RST
PB6Output软串口 TX
PB7Input + Pull-up软串口 RX

GPIO
TIM3:

参数
Prescaler249
Counter Period9
NVIC开启 TIM3 global interrupt

TIM

时钟树:
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 不发新数据,屏幕文本自动清空,方便下次输入。

总结

这个实验有两个关键点:

  1. 三倍过采样是用纯定时器实现软串口的基础,兼顾了时序精度和配置简洁
  2. 连续字节的起始位检测容易被忽略,必须在一个 ISR 内完成"收尾—检测"的闭环

代码量不大,核心的软串口就一百多行,但它跑通了 UART 的全部底层逻辑。把这个搞明白了,再回头看 STM32 的 USART 硬件外设,很多寄存器的设计意图就一目了然了。

参考

  • STM32F103C8T6 数据手册
  • ST7735 数据手册
  • Adafruit ST7735 Library
Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐