4. GPIO模拟UART:用定时器+GPIO实现简易UART发送

说实话,很多工程师第一次接触UART,都是直接用硬件外设。但硬件UART一旦坏了,或者芯片引脚不够用,你就得靠GPIO硬怼。我当年做一款小家电,主控只有两组UART,硬是被产品经理塞了三个串口设备……没办法,第三个口只能拿GPIO模拟。

这一章,我们就来手撸一个GPIO模拟UART发送。目标很简单:用定时器+GPIO,在9600波特率下,把数据发出去。你会彻底搞懂UART的位时序到底是怎么回事。

本章核心:用定时器产生精确的位时间,用GPIO模拟TX引脚的电平变化,实现标准的UART异步发送。

4.1 UART发送的位时序拆解

UART异步发送,说白了就是一根线,按约定的速率,一比特一比特地往外送。标准的一帧数据长这样:

位域 电平 时长 说明
起始位 低电平 (0) 1 个位时间 通知接收方:我要开始发了
数据位 (LSB first) 数据 bit0 ~ bit7 8 个位时间 先发最低位,再发最高位
校验位 (可选) 奇/偶校验 1 个位时间 这章我们不用,跳过
停止位 高电平 (1) 1 个位时间 告诉接收方:这帧结束了

你看,一帧最少10个位时间(1起始+8数据+1停止)。9600波特率意味着每秒发9600个位,每个位的时间就是:

位时间 = 1 / 9600 ≈ 104.17 微秒

嗯,104微秒。这个时间,用定时器来卡,刚刚好。

4.2 定时器配置:卡准104微秒

我习惯用硬件定时器的比较匹配中断来做。为什么不用延时函数?因为延时函数会阻塞CPU,你发数据的时候啥也干不了。用定时器中断,CPU可以在位与位之间干别的事。

假设你的系统时钟是72MHz(比如STM32F103),定时器预分频后得到1MHz的计数时钟,那么一个计数周期就是1微秒。要卡104微秒,就设比较值为104。

/* 定时器初始化:1MHz计数,比较值104 */
void timer_init_for_uart(void) {
    /* 假设 TIM2, 72MHz 时钟 */
    TIM2->PSC = 72 - 1;      /* 预分频:72MHz / 72 = 1MHz */
    TIM2->ARR = 104 - 1;     /* 自动重装:104 个计数 */
    TIM2->DIER |= TIM_DIER_UIE; /* 使能更新中断 */
    NVIC_EnableIRQ(TIM2_IRQn);
}
小技巧:104微秒是理论值。实际晶振可能有误差,如果通信不稳定,可以微调ARR值。我曾经遇到过晶振偏大0.5%,导致连续发几十个字节后错位,调成103就稳了。

4.3 GPIO模拟发送的状态机

你不能在中断里直接发完一整帧。中断服务函数应该只处理一个位,然后退出。所以我们需要一个状态机,记录当前发到了哪一位。

我设计了一个简单的发送状态机:

/* 发送状态 */
typedef enum {
    TX_IDLE,        /* 空闲,等待发送 */
    TX_START,       /* 发送起始位 */
    TX_DATA,        /* 发送数据位 */
    TX_STOP         /* 发送停止位 */
} tx_state_t;

/* 发送控制块 */
typedef struct {
    volatile tx_state_t state;
    volatile uint8_t    data;       /* 待发送的字节 */
    volatile uint8_t    bit_count;  /* 已发送的数据位数 */
    volatile uint8_t    busy;       /* 发送忙标志 */
} uart_tx_t;

uart_tx_t g_tx = {TX_IDLE, 0, 0, 0};

发送一个字节的流程:

  1. 应用程序调用 uart_send_byte(0x55),把数据写入控制块,状态切到 TX_START。
  2. 定时器中断到来,根据当前状态,拉高或拉低TX引脚。
  3. 每处理完一个位,状态推进到下一步。
  4. 停止位发送完成后,状态回到 TX_IDLE,清忙标志。

4.4 定时器中断服务函数:逐位发送

这是整个模拟UART的核心。每次中断只处理一个位,然后退出。代码不长,但逻辑要严谨。

void TIM2_IRQHandler(void) {
    if (TIM2->SR & TIM_SR_UIF) {
        TIM2->SR = ~TIM_SR_UIF;  /* 清除中断标志 */

        switch (g_tx.state) {
            case TX_START:
                /* 拉低TX,发送起始位 */
                GPIO_ResetBits(GPIOA, GPIO_Pin_9);
                g_tx.state = TX_DATA;
                g_tx.bit_count = 0;
                break;

            case TX_DATA:
                /* 发送当前数据位,LSB first */
                if (g_tx.data & 0x01) {
                    GPIO_SetBits(GPIOA, GPIO_Pin_9);   /* 发1 */
                } else {
                    GPIO_ResetBits(GPIOA, GPIO_Pin_9); /* 发0 */
                }
                g_tx.data >>= 1;   /* 右移,准备下一位 */
                g_tx.bit_count++;
                if (g_tx.bit_count >= 8) {
                    g_tx.state = TX_STOP;
                }
                break;

            case TX_STOP:
                /* 拉高TX,发送停止位 */
                GPIO_SetBits(GPIOA, GPIO_Pin_9);
                g_tx.state = TX_IDLE;
                g_tx.busy = 0;      /* 释放忙标志 */
                break;

            default:
                /* IDLE状态:TX保持高电平 */
                GPIO_SetBits(GPIOA, GPIO_Pin_9);
                break;
        }
    }
}
注意:中断服务函数里不要做复杂运算,不要调用printf。我曾经见过有人在中断里做除法,结果一个中断执行了200微秒,直接把时序搞崩了。中断里只做位操作和状态切换,其他事情放到主循环做。

4.5 发送接口与主循环调用

应用程序调用发送接口,把数据塞进去,然后轮询忙标志,或者用回调通知完成。

/* 发送一个字节(非阻塞) */
uint8_t uart_send_byte(uint8_t data) {
    if (g_tx.busy) {
        return 0;   /* 发送忙,返回失败 */
    }
    g_tx.data = data;
    g_tx.busy = 1;
    g_tx.state = TX_START;
    return 1;       /* 成功启动发送 */
}

/* 主循环中轮询发送完成 */
int main(void) {
    timer_init_for_uart();
    GPIO_Init();    /* PA9 配置为推挽输出 */

    uart_send_byte(0x55);

    while (1) {
        if (!g_tx.busy) {
            /* 上一帧发完了,可以发下一帧 */
            uart_send_byte(0xAA);
            delay_ms(100);
        }
    }
}

你看,主循环里只需要检查 g_tx.busy 就知道数据发完没有。定时器中断在后台默默地把每一位送出去。

4.6 实战验证:9600波特率发送0x55

0x55 这个字节很特殊,二进制是 01010101。在UART线路上,LSB first发送,实际波形是:

  • 起始位:低
  • bit0: 1
  • bit1: 0
  • bit2: 1
  • bit3: 0
  • bit4: 1
  • bit5: 0
  • bit6: 1
  • bit7: 0
  • 停止位:高

用逻辑分析仪抓一下,你会看到一串方波,每个电平持续104微秒,高低交替。这就是最原始的UART波形。

验证方法:把TX引脚接到USB转串口工具的RXD,打开串口助手,设置9600波特率。如果代码正确,你会看到连续收到 0x55 和 0xAA。如果收到乱码,先检查波特率误差,再检查GPIO电平是否反相。

4.7 避坑指南:我踩过的几个坑

做GPIO模拟UART,有几个地方特别容易翻车:

  • 中断优先级:如果系统里还有其他中断,确保定时器中断优先级够高。我曾经把定时器中断设成最低优先级,结果被其他中断打断,位时间被拉长,接收方直接超时。
  • GPIO速度:有些MCU的GPIO输出速度可以配置。如果设成低速,上升沿会变缓,波特率高了容易误码。9600还好,115200就得注意了。
  • 连续发送:两个字节之间,停止位之后必须保持高电平至少一个位时间。如果你连续调用 uart_send_byte,要确保上一帧的停止位已经发完。
  • 临界区保护:如果主循环和中断同时访问 g_tx 结构体,记得关中断再修改。否则可能读到中间状态。

4.8 本章知识图谱

下面这张图,把GPIO模拟UART发送的完整流程串起来了。从应用层调用,到定时器中断逐位发送,再到物理层波形,一目了然。

GPIO模拟UART发送流程 应用调用 uart_send_byte() 状态机:IDLE → START 定时器中断触发 (104μs) START: 拉低TX DATA: 逐位发送 STOP: 拉高TX 状态推进 / 清除中断标志 退出中断,等待下一周期 每个中断只处理1个位 10次中断完成1帧发送