1. 项目概述
在嵌入式开发中,串口通信是最基础也最常用的调试手段之一。但实际项目中经常会遇到硬件串口资源不足的情况,比如STM32F103系列只有3个硬件UART,当需要连接多个串口设备时就捉襟见肘了。这时候,用普通GPIO模拟串口(软件UART)就成了一种非常实用的解决方案。
我最近在一个智能家居网关项目中就遇到了这个问题:需要同时连接WiFi模块、蓝牙模块和调试终端,但硬件UART不够用。通过GPIO模拟的软件UART完美解决了这个难题,实测在9600波特率下稳定可靠。下面就把这个方案的实现细节完整分享出来,包括原理、代码和实际应用中的避坑经验。
2. 硬件准备与配置
2.1 硬件选型
本项目使用STM32F103ZET6作为主控芯片,这是一款基于Cortex-M3内核的MCU,主频72MHz,具有丰富的GPIO资源。选择它的原因主要有三点:
- 性价比高,市场保有量大
- 性能足够应对软件UART的时序要求
- 开发工具链成熟,资料丰富
其他硬件包括:
- CH340 USB转串口模块:用于与PC通信
- 杜邦线若干:连接电路
- ST-Link调试器:程序下载与调试
2.2 STM32CubeMX配置
使用STM32CubeMX进行基础配置可以大幅提高开发效率。关键配置步骤如下:
-
时钟配置:
- 启用外部高速晶振(HSE)
- 系统时钟设置为72MHz
- APB1总线时钟36MHz,APB2总线时钟72MHz
-
GPIO配置:
- PB7配置为推挽输出模式(模拟TX)
- PB8配置为上拉输入模式(模拟RX)
- 其他GPIO保持默认
-
SYS配置:
- Debug设置为Serial Wire模式
- 时基源选择SysTick
-
工程配置:
- 工具链选择MDK-ARM
- 勾选"Generate peripheral initialization as a pair of .c/.h files"
注意:一定要正确配置时钟树,否则微秒级延时函数将不准确,导致软件UART无法正常工作。我曾在一个项目中因为时钟配置错误,导致9600波特率实际变成了4800,排查了半天才发现是时钟问题。
3. 软件UART实现原理
3.1 串口通信基础
UART通信有几个关键参数需要了解:
- 波特率:常见的有9600、19200、115200等,表示每秒传输的位数
- 数据位:通常8位
- 停止位:通常1位
- 校验位:可选,本方案中不使用
一帧数据的格式如下:
code复制[起始位(0)] [数据位0] [数据位1] ... [数据位7] [停止位(1)]
总长度10位(无校验位时)。
3.2 位周期计算
以9600波特率为例:
- 位周期 = 1/9600 ≈ 104.167μs
- 即每个bit的电平需要保持104μs左右
这是软件UART实现的关键,必须精确控制每个电平的持续时间。
3.3 发送时序实现
发送一个字节的流程:
- 拉低TX引脚(起始位)
- 延时1个位周期
- 依次发送8个数据位(LSB first),每位保持1个位周期
- 拉高TX引脚(停止位)
- 延时1个位周期
3.4 接收时序实现
接收一个字节的流程:
- 检测RX引脚下降沿(起始位)
- 延时1.5个位周期(中位采样)
- 每隔1个位周期采样一次RX引脚,共采样8次
- 等待停止位(高电平)
中位采样是关键技巧,可以避开信号边沿的不稳定区域,大大提高接收稳定性。我在早期版本中没有使用这个技巧,误码率很高,加入1.5倍延时后问题完全解决。
4. 代码实现详解
4.1 微秒级延时函数
精准的延时是软件UART的基础。这里利用SysTick定时器实现微秒级延时:
c复制void HAL_Delay_Us(uint32_t us)
{
uint32_t ticks;
uint32_t told, tnow, tcnt = 0;
ticks = us * (SYSCLK / 1000000); // 计算需要的时钟周期数
told = SysTick->VAL; // 获取当前SysTick值
while(1) {
tnow = SysTick->VAL;
if(tnow != told) {
if(tnow < told)
tcnt += told - tnow;
else
tcnt += SysTick->LOAD - tnow + told;
told = tnow;
if(tcnt >= ticks)
break;
}
}
}
这个函数的误差在1us以内,完全满足9600波特率的要求(位周期104us)。
4.2 字节发送函数
c复制void SoftUART_SendByte(uint8_t data)
{
uint8_t i;
// 发送起始位
TX_LOW();
HAL_Delay_Us(BIT_DELAY_US);
// 发送8位数据位(LSB first)
for(i = 0; i < 8; i++) {
if(data & 0x01)
TX_HIGH();
else
TX_LOW();
HAL_Delay_Us(BIT_DELAY_US);
data >>= 1;
}
// 发送停止位
TX_HIGH();
HAL_Delay_Us(BIT_DELAY_US);
}
4.3 字节接收函数
c复制uint8_t SoftUART_ReceiveByte(void)
{
uint8_t i, recv_data = 0;
// 等待起始位下降沿
while(RX_READ() == 1);
// 中位采样:延时1.5个位周期
HAL_Delay_Us(BIT_DELAY_US + (BIT_DELAY_US >> 1));
// 采样8位数据
for(i = 0; i < 8; i++) {
recv_data >>= 1;
if(RX_READ() == 1)
recv_data |= 0x80;
HAL_Delay_Us(BIT_DELAY_US);
}
// 等待停止位
while(RX_READ() == 0);
return recv_data;
}
4.4 高级功能实现
为了方便使用,还实现了以下功能函数:
SoftUART_SendString():发送字符串SoftUART_SendNum():发送数字(支持十进制和十六进制)SoftUART_printf():格式化输出,类似标准printf
这些函数都基于基础的字节发送/接收函数实现,极大提高了代码的实用性。
5. 实际应用与优化
5.1 性能测试
在STM32F103C8T6(72MHz)上测试不同波特率的稳定性:
| 波特率 | 最大误差 | 稳定性 |
|---|---|---|
| 9600 | ±2% | ★★★★★ |
| 19200 | ±3% | ★★★★☆ |
| 38400 | ±5% | ★★★☆☆ |
| 57600 | ±8% | ★★☆☆☆ |
| 115200 | ±15% | ★☆☆☆☆ |
建议在实际项目中使用9600或19200波特率,更高的波特率虽然理论可行,但误码率会明显上升。
5.2 中断优化方案
基础版本采用轮询方式接收数据,会占用较多CPU资源。可以通过以下优化提高效率:
- 使用外部中断检测起始位下降沿
- 定时器中断实现位采样
- 环形缓冲区存储接收数据
这种方案实现起来更复杂,但可以大幅降低CPU占用率,适合高负载系统。
5.3 多软件UART实例
通过合理设计代码结构,可以轻松实现多个软件UART实例。只需为每个实例分配不同的GPIO引脚,并管理好各自的时序即可。
在我的智能家居网关项目中,就同时使用了3个软件UART,分别连接不同的外设模块,运行非常稳定。
6. 常见问题与解决方案
6.1 数据接收不完整
现象:只能收到部分数据,或者数据错位
原因:
- 延时函数不准确
- 中断优先级配置不当
- 系统负载过高
解决方案:
- 检查系统时钟配置
- 优化延时函数,必要时使用硬件定时器
- 降低系统负载或提高MCU主频
6.2 通信距离短
现象:通信距离超过1米就不稳定
原因:GPIO驱动能力有限,信号衰减严重
解决方案:
- 降低波特率
- 增加线路驱动芯片如MAX3232
- 使用差分信号传输
6.3 与其他外设冲突
现象:软件UART工作时其他外设异常
原因:CPU资源被过度占用
解决方案:
- 优化代码结构,减少阻塞时间
- 使用DMA或中断方式处理其他外设
- 考虑使用硬件UART+软件UART混合方案
7. 项目扩展思路
这个基础方案可以进一步扩展:
- 无线化:结合nRF24L01等无线模块,实现无线串口功能
- 协议扩展:在基础UART上实现Modbus等工业协议
- 调试接口:开发配套的上位机软件,增强调试功能
- 性能优化:使用汇编优化关键时序部分,提高波特率上限
我在实际项目中就曾基于这个方案开发了一个无线调试终端,通过2.4G无线模块实现了设备状态的远程监控,大大提高了调试效率。