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发送的流程,说白了就是:
- 拉低TX引脚(起始位)
- 延时一个bit的时间
- 依次输出数据位的第0位到第7位(LSB first)
- 每输出一位,延时一个bit的时间
- 拉高TX引脚(停止位)
- 再延时一个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)的波形结构:
看到没?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时,心里就有底了——你知道它在背后干了什么。