4. GPIO模拟UART发送:基于延时函数的bit-bang发送实现

好,咱们进入实战环节。前面几章我们把UART协议拆了个底朝天,从起始位到停止位,从波特率到数据格式,该有的概念都有了。但说实话,光看协议不写代码,总觉得隔着一层纱。

这一章,我们就用最原始的方式——GPIO + 延时函数,手撸一个UART发送器。我管这叫“bit-bang”,说白了就是拿IO口逐位地“敲”出波形来。你可能会问:现在MCU不都自带UART外设吗?干嘛还要自己模拟?

嗯,我在项目里遇到过两次这种情况:一次是芯片UART引脚被复用了,另一次是某款低成本MCU压根没有硬件UART。这时候,bit-bang就是你的救命稻草。

4.1 bit-bang发送的核心思想

先理清思路。UART发送的本质是什么?就是在一根TX线上,按约定的波特率,把数据帧的每一位依次输出。

一个标准的数据帧长这样:

起始位 数据位(LSB first) 校验位(可选) 停止位
1位低电平 5~8位数据 奇/偶校验 1位高电平

所以bit-bang发送的流程,说白了就是:

  1. 拉低TX引脚(起始位)
  2. 延时一个bit的时间
  3. 依次输出数据位的第0位到第7位(LSB first)
  4. 每输出一位,延时一个bit的时间
  5. 拉高TX引脚(停止位)
  6. 再延时一个bit的时间

就这么简单?对,就这么简单。但魔鬼都在细节里。

关键指标:bit-bang发送的成败,完全取决于延时精度。波特率误差超过±2%,接收端就可能采样错位。我见过最惨的情况——波特率偏了5%,两边数据全乱,查了三天才发现是延时函数没校准。

4.2 延时函数的实现与校准

延时函数是bit-bang的基石。你想想看,如果延时不准,每一位的宽度就不一致,接收端按标准波特率来采样,能不丢数据吗?

常用的延时方式有两种:

方式 原理 精度 适用场景
空循环延时 执行N条空指令 依赖编译器优化和主频 简单演示、低频波特率
硬件定时器延时 配置定时器产生精确中断或查询 高,不受编译器影响 生产级代码、高频波特率

我个人习惯,在课程里先用空循环,因为代码简单,逻辑一目了然。但我会告诉你如何校准,以及什么时候该换定时器。

来看一个典型的空循环延时函数:

// 假设系统主频 16MHz,空循环大约 8 个周期/次
void delay_us(uint32_t us) {
    // 粗略估算:us * (F_CPU / 8 / 1000000)
    // 实际值需要示波器校准
    uint32_t loops = us * 2;  // 经验系数,待校准
    while (loops--) {
        __NOP();  // 空操作,防止被优化掉
    }
}

注意:空循环延时极度依赖编译器优化等级。你用-O0编译和-O2编译,延时可能差好几倍。我踩过这个坑——调试时好好的,一开优化就乱码。所以,生产代码请用硬件定时器。

4.3 逐位发送的实现

好,延时函数有了,接下来就是核心的发送逻辑。我习惯把发送一个字节封装成一个函数,这样上层调用就干净了。

// 定义TX引脚
#define TX_PIN    GPIO_PIN_9
#define TX_PORT   GPIOA

// 宏定义:拉高/拉低TX
#define TX_HIGH()  HAL_GPIO_WritePin(TX_PORT, TX_PIN, GPIO_PIN_SET)
#define TX_LOW()   HAL_GPIO_WritePin(TX_PORT, TX_PIN, GPIO_PIN_RESET)

// 波特率相关:假设 115200bps,每位约 8.68us
#define BIT_TIME_US  87  // 实际取整为 87us,误差约0.2%

void uart_bitbang_send_byte(uint8_t data) {
    // 1. 起始位:拉低
    TX_LOW();
    delay_us(BIT_TIME_US);

    // 2. 数据位:LSB first,共8位
    for (uint8_t i = 0; i < 8; i++) {
        if (data & 0x01) {
            TX_HIGH();
        } else {
            TX_LOW();
        }
        delay_us(BIT_TIME_US);
        data >>= 1;  // 右移,准备下一位
    }

    // 3. 停止位:拉高
    TX_HIGH();
    delay_us(BIT_TIME_US);
}

这段代码,我建议你一行一行读。注意那个 data >>= 1 的位置——它是在延时之后才移位。为什么?因为当前位已经输出完了,才准备下一位。如果你在延时之前移位,那输出的第一位就变成了原数据的第1位,而不是第0位。嗯,这个顺序搞反了,数据就全乱了。

小技巧:如果你想让代码更通用,可以把数据位长度也做成参数。比如有些设备用7位数据,有些用9位。我一般这样写:void uart_bitbang_send(uint16_t data, uint8_t bits),然后循环 bits 次。这样一套代码通吃所有格式。

4.4 时序验证:用逻辑分析仪说话

代码写完了,怎么知道它对不对?别靠猜。我见过太多人写完代码就烧进去,然后对着串口助手发呆——收不到数据就怀疑代码,其实波形根本不对。

我的习惯是:先看波形,再谈通信。用逻辑分析仪或者示波器抓一下TX引脚的波形,一眼就能看出问题。

下面这张图,就是bit-bang发送一个字节 0x55(二进制 01010101)的波形结构:

bit-bang发送 0x55 波形结构 起始位 低电平 D0=1 高电平 D1=0 低电平 D2=1 高电平 D3=0 低电平 D4=1 高电平 D5=0 低电平 D6=1 高电平 停止位 高电平 D7=0 低电平 时间轴 → 注意:LSB first,D0最先发送

看到没?0x55 的二进制是 01010101,LSB first 发送时,先发最低位 D0=1,然后 D1=0,D2=1……依次类推。波形就是高低交替的方波。如果你抓到的波形不是这样,那就要检查代码了。

4.5 常见问题与避坑指南

写bit-bang发送,有几个坑我几乎每次都会遇到。列出来,你遇到了直接对照排查:

  • 波特率不准:延时函数没校准,或者被中断打断了。解决办法:关中断,或者用定时器。
  • 数据反了:忘记LSB first,或者移位方向搞反。检查 data >>= 1 和循环顺序。
  • 停止位太短:停止位延时被漏了,或者延时不够。接收端可能把下一帧的起始位当成数据。
  • GPIO模式没配:TX引脚要配成推挽输出,开漏输出需要外部上拉。我见过有人配成输入,拉了半天电平纹丝不动。

我曾经在一个项目里,bit-bang发送9600波特率的数据,结果接收端总是丢第一个字节。查了两天,发现是发送完停止位后,没有等一帧的间隔就直接发下一帧了。接收端还没准备好,起始位就被吞了。解决方案:在每帧之间加一个bit时间的空闲期。

4.6 完整示例:发送字符串

单个字节能发了,那字符串呢?很简单,循环调用就行。但要注意,字符串发送不能太快,否则接收端缓冲区可能溢出。

void uart_bitbang_send_string(const char *str) {
    while (*str != '\0') {
        uart_bitbang_send_byte((uint8_t)(*str));
        str++;
        // 帧间间隔:至少一个bit时间
        delay_us(BIT_TIME_US);
    }
}

这段代码,你可以在主函数里调用 uart_bitbang_send_string("Hello, UART!\r\n");,然后用逻辑分析仪抓波形,或者接一个USB转串口模块看电脑能不能收到。

个人经验:第一次测试时,建议先发一个固定的字节,比如 0x55 或 0xAA。这两个字节的波形是规律的方波,一眼就能看出波特率对不对。如果波形宽度不对,调延时系数;如果波形形状不对,检查代码逻辑。等这个字节通了,再发字符串。

4.7 小结

这一章,我们从零开始,用GPIO和延时函数实现了一个UART发送器。核心就三件事:精确延时、逐位输出、LSB first。代码量不大,但每一步都考验你对时序的理解。

你可能会觉得,这比直接用硬件UART麻烦多了。没错,但bit-bang的价值在于:它让你真正理解UART的每一根毛刺。以后你用硬件UART时,心里就有底了——你知道它在背后干了什么。