简介:这份资源面向在 IAR Embedded Workbench 下开发 STM8S003 的嵌入式工程师与学习者,针对该芯片原生物理串口资源不足的问题,给出基于 PC3、PC4 引脚配合定时器实现软件模拟串口(Software UART)的完整工程。内容涵盖 GPIO 推挽/开漏配置、波特率定时器设定、发送波形生成、查询与中断两种接收模式的处理逻辑,以及中断服务程序中的数据收发流程,适合已具备一定单片机基础、希望掌握资源受限场景下串口扩展思路的开发者参考。压缩包共 53 个文件,约 418KB,以 C 源码与头文件为核心,辅以 IAR 工程与调试配置文件、编译中间产物及日志,导入后即可编译调试。目前已有 425 人学习下载。通过研读示例代码与工程配置,读者可理解模拟串口的时序实现与中断接收机制,并在此基础上扩展多路模拟串口或适配不同波特率。
1. STM8S003 没有独立串口时,用 IAR 把 GPIO 模拟成 UART 的完整落地路径
手上有一块 STM8S003 的小板子,引脚排下来发现真正能用的 UART 只有一组,结果被传感器和调试口同时盯上,硬件串口根本不够分。这种场景在低成本项目里太常见了:芯片便宜、外设少,但功能需求一点没减。模拟串口就是在这种夹缝里长出来的方案——用两个普通 GPIO,一个当 TX 一个当 RX,靠定时器掐时间位,把电平一位一位搬出去。它不占硬件 UART,不挑引脚,代价是吃 CPU 时序和中断响应。这篇笔记围绕 IAR 环境下 STM8S003 的模拟串口实现展开,从波特率怎么算、定时器怎么配,到收发状态机怎么写、示波器上波形为什么会歪,全部落到能直接抄的代码和参数上。适合正在用 STM8S003 做小体积产品、被串口数量卡住、又不想换芯片的嵌入式从业者。
2. 模拟串口的时间基准:波特率、定时器与采样点怎么定
模拟串口的本质是把「异步串行」这件事用软件重新实现一遍。硬件 UART 内部有一个波特率发生器,发送时按位宽移位,接收时在位的中间采样;软件模拟要做的是同一件事,只不过时间基准换成了 MCU 的定时器,移位换成了 GPIO 电平翻转。理解这一点,后面所有参数才有依据,而不是抄一组数就完事。
2.1 一位到底多宽:从波特率到定时器计数值
以常见的 9600 波特率为例,一位的时间是 1/9600 ≈ 104.17 微秒。STM8S003 在 16MHz 主频下,一个机器周期是 62.5 纳秒,定时器 1 的计数时钟如果取 1 分频,计数值就是 104.17µs / 62.5ns ≈ 1667。这个数字不是随便取的,它直接决定波形每一位的宽度误差。实际工程里更常见的做法是把定时器时钟再分频,让计数值落在一个整数附近,减少累计误差。
| 参数 | 取值 | 说明 |
|---|---|---|
| 系统时钟 | 16 MHz | STM8S003 内部 HSI 默认值,也可外部晶振 |
| 定时器时钟分频 | 1 | TIM1 计数频率 16 MHz |
| 目标波特率 | 9600 | 一位约 104.17 µs |
| 理论计数值 | 1667 | 104.17µs × 16MHz |
| 实际写入值 | 1667 | 误差约 0.02%,可接受 |
| 采样点 | 位中点 | 接收时在起始位下降沿后 1.5 位处首次采样 |
需要提醒的是,模拟串口的误差是累计的。发送 10 位(1 起始 + 8 数据 + 1 停止),如果每位偏差 1 个计数,累计就是 10 个计数,约 0.6µs,对 104µs 的位宽来说仍在容忍范围内;但如果分频选错导致每位偏差 5%,第 8 位就可能采到相邻位上去。所以定时器初值一定要按实际主频重算,不能照搬别人的 1667。
2.2 定时器选型:TIM1 还是 TIM4
STM8S003 上有 TIM1(16 位高级定时器)和 TIM4(8 位基本定时器)。模拟串口对定时器的要求是:计数范围要覆盖一位的计数值,中断要足够快且不能被其他长中断拖住。9600 波特率下 1667 的计数值,8 位的 TIM4 最大只能到 255,必须靠预分频把计数频率降下来,比如 64 分频后计数值约 26,精度就差了。所以常见做法是用 TIM1 做 1 分频精确定时,TIM4 留给系统滴答或其他慢速任务。
/* TIM1 初始化:1 分频,向上计数,用于模拟串口位定时 */ void SimUart_TimerInit(void) { TIM1_DeInit(); TIM1_TimeBaseInit(0, TIM1_COUNTERMODE_UP, 1667, 0); /* 16MHz/1, 计数值1667 */ TIM1_ITConfig(TIM1_IT_UPDATE, ENABLE); /* 使能更新中断 */ TIM1_Cmd(ENABLE); } /* 中断服务:每个位周期进入一次,驱动收发状态机 */ #pragma vector = TIM1_OVR_UIF_vector __interrupt void TIM1_UPD_ISR(void) { TIM1_ClearITPendingBit(TIM1_IT_UPDATE); SimUart_Tick(); /* 状态机推进一步 */ }这段代码的关键在TIM1_TimeBaseInit的第三个参数,它就是一位的计数值。改波特率时只改这一个数,比如 19200 就改成 833,115200 改成 139。中断里不做具体收发,只调用一个 tick 函数,是为了让状态机逻辑集中、便于调试。TIM1_ClearITPendingBit必须放在最前面,否则中断会反复进入,这是 STM8 上很典型的一个翻车点。
2.3 收发采样点:为什么接收比发送难
发送相对简单:定时器每到一个位周期,就把数据字节的对应位推到 TX 引脚上,10 个周期发完一帧。接收难在同步——发送方和接收方各自跑自己的时钟,没有共享时钟线,接收方只能靠起始位的下降沿对齐,然后在每位的中点采样。中点采样的原因是:位边缘附近电平正在跳变,最容易读错,中点最稳。
实现上,接收状态机在空闲时等待 RX 引脚下降沿(可以用外部中断,也可以定时器轮询),检测到下降沿后启动定时器,第一次中断发生在 1.5 个位周期后(跳过起始位并进入第 0 位中点),之后每个位周期采一次,采满 8 位后检查停止位是否为高。这个 1.5 位的偏移是模拟串口接收的核心参数,写错就整帧错位。
提示:如果 RX 引脚同时接了其他外设,下降沿检测可能被干扰触发,建议在起始位确认时连续采样两次,两次都为低才认为有效起始位。
3. IAR 工程里的模拟串口实现:GPIO 配置、状态机与收发函数
这一章落到 IAR 工程的实际组织方式。STM8S003 的 GPIO 配置和 STM32 不一样,方向寄存器 DDR、输出模式 CR1、上拉 CR2 要分开设,漏一个就会出现「引脚没反应」的玄学问题。下面按发送、接收、主循环调用三块拆开写。
3.1 GPIO 初始化:TX 推挽输出、RX 上拉输入
TX 引脚要能主动拉高拉低,配成推挽输出;RX 引脚要能被外部拉低,配成输入并开上拉,避免悬空时读到随机电平。STM8S003 的 PD5、PD6 是常用组合,具体引脚按板子走线改。
/* 模拟串口 GPIO 初始化:TX=PD5 推挽输出,RX=PD6 上拉输入 */ void SimUart_GpioInit(void) { /* TX: PD5 输出,推挽,低速 */ PD_DDR |= (1 << 5); /* 方向:输出 */ PD_CR1 |= (1 << 5); /* 推挽模式 */ PD_CR2 &= ~(1 << 5); /* 低速 */ /* RX: PD6 输入,上拉 */ PD_DDR &= ~(1 << 6); /* 方向:输入 */ PD_CR1 |= (1 << 6); /* 上拉输入 */ PD_CR2 &= ~(1 << 6); PD_ODR |= (1 << 5); /* TX 空闲态为高 */ }PD_DDR决定方向,PD_CR1在输出时选推挽/开漏、在输入时选上拉/浮空,PD_CR2管速度或中断。三个寄存器各管一摊,这是 STM8 和 STM32 最大的手感差异。TX 空闲态必须置高,因为 UART 协议里空闲是高电平,起始位才是低电平;如果初始化后 TX 是低,接收方会误以为一直有起始位。
2.2 发送状态机:一个字节怎么拆成 10 个位周期
发送用一个结构体维护当前字节、位计数和忙标志。调用发送函数时把字节装进去、拉低 TX 产生起始位、启动定时器,之后每个 tick 推一位。
typedef struct { uint8_t data; /* 待发送字节 */ uint8_t bitIdx; /* 当前位索引 0~9 */ uint8_t busy; /* 忙标志 */ } SimUartTx_t; static SimUartTx_t tx; /* 发送一个字节(阻塞式启动,非阻塞式完成) */ void SimUart_SendByte(uint8_t byte) { while (tx.busy); /* 等待上一字节发完 */ tx.data = byte; tx.bitIdx = 0; tx.busy = 1; PD_ODR &= ~(1 << 5); /* 起始位:拉低 */ TIM1_SetCounter(0); /* 重置计数,对齐位宽 */ TIM1_Cmd(ENABLE); } /* 定时器 tick 中调用:每位推一次电平 */ void SimUart_TxTick(void) { if (!tx.busy) return; tx.bitIdx++; if (tx.bitIdx <= 8) { /* 数据位,LSB 先发 */ if (tx.data & 0x01) PD_ODR |= (1 << 5); else PD_ODR &= ~(1 << 5); tx.data >>= 1; } else if (tx.bitIdx == 9) { PD_ODR |= (1 << 5); /* 停止位:拉高 */ } else { tx.busy = 0; /* 一帧结束 */ TIM1_Cmd(DISABLE); } }位索引用 0 到 9 共 10 个周期:第 0 周期是起始位(在 SendByte 里已经拉低),第 1 到 8 周期推 8 个数据位,第 9 周期推停止位。数据位 LSB 先发,这是 UART 标准,写反了收到的就是位翻转的乱码。TIM1_SetCounter(0)在启动时重置计数,保证起始位宽度和其他位一致,否则第一位的宽度会受上一次计数残留影响。
3.3 接收状态机:下降沿对齐与中点采样
接收用外部中断捕获起始位,然后交给定时器逐位采样。STM8S003 的 PD 口支持外部中断,配置时注意 CR2 寄存器要开中断位。
typedef struct { uint8_t data; uint8_t bitIdx; uint8_t active; } SimUartRx_t; static SimUartRx_t rx; /* PD 口外部中断:检测起始位下降沿 */ #pragma vector = EXTI_PORTD_vector __interrupt void EXTI_PD_ISR(void) { if (!rx.active && !(PD_IDR & (1 << 6))) { rx.active = 1; rx.bitIdx = 0; rx.data = 0; TIM1_SetCounter(0); TIM1_Cmd(ENABLE); /* 启动位定时 */ } } /* 定时器 tick 中调用:1.5 位后首次采样,之后每位一次 */ void SimUart_RxTick(void) { static uint8_t half = 0; if (!rx.active) return; if (!half) { /* 第一次中断:1.5 位处,跳过半个位 */ half = 1; return; } if (rx.bitIdx < 8) { if (PD_IDR & (1 << 6)) rx.data |= (1 << rx.bitIdx); rx.bitIdx++; } else { /* 停止位检查 */ if (PD_IDR & (1 << 6)) { SimUart_RxCallback(rx.data); /* 交给上层处理 */ } rx.active = 0; half = 0; TIM1_Cmd(DISABLE); } }这里用了一个half标志实现 1.5 位偏移:外部中断在下降沿触发,定时器第一次溢出是 1 个位周期后,此时还在起始位内,再等一个周期才到第 0 位中点,所以第一次 tick 直接返回,第二次才开始采样。这个细节如果漏掉,采到的就是起始位而不是数据位,整帧右移一位,表现为收到的字节总是原值的两倍或带固定偏移。
注意:接收期间如果系统里有其他中断耗时超过半个位宽(9600 下约 52µs),采样点会漂移。常见做法是在接收窗口内关掉非必要中断,或者把模拟串口接收放到最高优先级。
4. 避坑与排查:模拟串口在 STM8S003 上最容易翻车的五件事
模拟串口没有硬件兜底,所有时序问题都会直接体现在波形和收到的数据上。下面五条是实际调试中反复出现的,按「现象 → 原因 → 解决」整理。
4.1 收到的全是 0xFF 或 0x00
现象:上位机收到的每个字节都是 0xFF 或 0x00,偶尔夹杂正确值。原因通常是 TX 引脚方向或模式配错,推挽没开导致引脚拉不动,或者空闲态没置高让接收方一直判为帧错误。解决:用万用表量 TX 空闲电压,应为 3.3V 左右;检查PD_DDR和PD_CR1是否都置位;确认PD_ODR初始化时置高。
4.2 波特率偏差导致后几位错位
现象:短帧(1 到 2 字节)正常,长帧从第 5、6 位开始出错。原因是定时器计数值按整数取整后存在固定偏差,帧越长累计越大。解决:按实际主频重算计数值,优先选能让计数值接近整数的分频组合;必要时在发送第 5 位后微调一次计数,把累计误差拉回。
4.3 中断里调用发送函数导致死等
现象:程序卡死,看门狗复位。原因是在定时器中断里调用了阻塞式SimUart_SendByte,而它内部while(tx.busy)等待的正是当前中断推进的状态,形成自锁。解决:中断里只做 tick,发送函数只在主循环调用;或者把发送改成纯状态机,调用即返回。
4.4 接收被其他中断打断丢位
现象:接收偶尔丢一个字节或某位翻转。原因是高优先级中断(比如 ADC 完成)耗时超过半个位宽,采样点被推迟。解决:接收窗口内临时关闭非必要中断,或把 TIM1 中断优先级提到最高;实在避不开就把波特率降到 4800,位宽翻倍,容忍度更高。
4.5 IAR 优化等级改变后时序变了
现象:Debug 版正常,Release 版波形变窄或变宽。原因是高优化等级下 GPIO 操作被重排或合并,位翻转之间的指令周期数变了。解决:对时序敏感的 GPIO 操作加volatile,关键段用__disable_interrupt()保护,或者把优化等级固定在 Low 并实测波形确认。
5. 用示波器和回环测试验证模拟串口:一套可复用的验收流程
代码写完不等于能用,模拟串口尤其如此,因为它的正确性完全依赖时序。我一般会走两步验证:先做 TX 单端波形检查,再做 TX-RX 回环压力测试。第一步用示波器抓 TX 引脚,发一个 0x55(二进制 01010101),这个值高低交替,最容易看出位宽是否均匀。触发设在下降沿,时基设 100µs/格,正常应该看到起始位低、8 位交替、停止位高,每位宽度一致。如果某一位明显偏宽或偏窄,回去查定时器计数值和中断响应时间。
第二步把 TX 和 RX 用杜邦线短接,做自发自收。写一个测试循环,连续发 0x00 到 0xFF 共 256 个字节,接收端比对,统计错误数。这个测试能暴露累计误差和中断冲突,比单发几个字节可靠得多。
/* 回环自测:TX-RX 短接,遍历 0x00~0xFF 并统计错误 */ void SimUart_LoopbackTest(void) { uint8_t i, err = 0; for (i = 0; i < 255; i++) { SimUart_SendByte(i); while (tx.busy); /* 等发完 */ if (SimUart_GetRxByte() != i) /* 取接收结果比对 */ err++; } /* err 应为 0;非 0 说明时序或采样点有问题 */ }跑这个测试时有个细节:发送和接收共用同一个定时器,如果发送还没结束就启动接收,两者会抢 tick。常见做法是发送完成后延时一个位周期再开接收,或者干脆用两个定时器分别管收发。STM8S003 资源紧张时,单定时器分时复用也能跑,但要在状态机里明确当前是发送态还是接收态,不能同时置位。
还有一个容易被忽略的点:回环测试通过不代表对接真实设备没问题。真实设备的波特率误差、电平幅度、线缆容抗都会影响结果。我习惯在回环通过后,再拿一个 USB 转串口模块对发至少 1000 帧,确认长时间运行不丢字节,才算验收完成。从那以后我每次做模拟串口,都强制先跑 0x55 波形再看回环错误数,两步不过不往下写业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取