4. GPIO模拟UART:用定时器+GPIO实现简易UART发送
说实话,很多工程师第一次接触UART,都是直接用硬件外设。但硬件UART一旦坏了,或者芯片引脚不够用,你就得靠GPIO硬怼。我当年做一款小家电,主控只有两组UART,硬是被产品经理塞了三个串口设备……没办法,第三个口只能拿GPIO模拟。
这一章,我们就来手撸一个GPIO模拟UART发送。目标很简单:用定时器+GPIO,在9600波特率下,把数据发出去。你会彻底搞懂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);
}
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};
发送一个字节的流程:
- 应用程序调用
uart_send_byte(0x55),把数据写入控制块,状态切到TX_START。 - 定时器中断到来,根据当前状态,拉高或拉低TX引脚。
- 每处理完一个位,状态推进到下一步。
- 停止位发送完成后,状态回到
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;
}
}
}
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波形。
4.7 避坑指南:我踩过的几个坑
做GPIO模拟UART,有几个地方特别容易翻车:
- 中断优先级:如果系统里还有其他中断,确保定时器中断优先级够高。我曾经把定时器中断设成最低优先级,结果被其他中断打断,位时间被拉长,接收方直接超时。
- GPIO速度:有些MCU的GPIO输出速度可以配置。如果设成低速,上升沿会变缓,波特率高了容易误码。9600还好,115200就得注意了。
- 连续发送:两个字节之间,停止位之后必须保持高电平至少一个位时间。如果你连续调用
uart_send_byte,要确保上一帧的停止位已经发完。 - 临界区保护:如果主循环和中断同时访问
g_tx结构体,记得关中断再修改。否则可能读到中间状态。
4.8 本章知识图谱
下面这张图,把GPIO模拟UART发送的完整流程串起来了。从应用层调用,到定时器中断逐位发送,再到物理层波形,一目了然。