STM8S003软件模拟串口实战:IAR环境下的时序控制与避坑指南
简介这份资源面向在IAR Embedded Workbench下开发STM8S003的嵌入式工程师与学习者针对该芯片原生物理串口资源不足的问题给出基于PC3、PC4引脚配合定时器实现软件模拟UART的完整工程。压缩包共53个文件约418KB以C源码与头文件为核心辅以IAR工程配置、编译输出、调试脚本及日志等类型文件覆盖从源码到可烧录固件的完整链路。资源中同时包含查询接收与中断接收两种模式的实现思路涉及GPIO推挽/开漏配置、定时器波特率设定、发送波形生成与接收电平捕获等关键环节并附有README说明导入与运行方式。目前已有423人学习下载适合希望掌握资源受限MCU串口扩展、并在此基础上扩展多路模拟串口或适配不同波特率的开发者参考。1. 为什么要在 IAR 里给 STM8S003 模拟串口STM8S003 这颗芯片在低成本工控板、小家电控制板里出镜率极高价格便宜、外设够用但它有一个让很多人第一次上手就皱眉的问题片上只有一个真正的 UART。当你已经用它接了 Modbus 从机、接了上位机调试口再想挂一个串口屏、一个无线模块或者第二路传感器时硬件串口就不够分了。这时候「模拟串口」就成了绕不开的方案——用两个普通 GPIO 加一个定时器靠软件翻转电平把 UART 时序拼出来。在 IAR EWSTM8 环境下做这件事难点不在写代码而在时序精度和中断干扰。STM8S003 跑 16MHz 时一条指令周期是 62.5ns115200 波特率下一位只有 8.68μs也就是约 139 个指令周期留给你的容错窗口非常窄。这篇文章就是把这个过程拆开怎么选定时器、怎么配 GPIO、发送和接收分别怎么写、IAR 里有哪些编译选项会偷偷影响时序以及我踩过的那些坑。适合已经能用 IAR 点亮 STM8S003、想再榨出一路串口的嵌入式工程师。2. 模拟串口的原理与 STM8S003 上的资源选型2.1 模拟串口到底在模拟什么UART 的物理层其实很简单空闲时线是高电平起始位拉低一位时间然后 8 位数据LSB 先发可选校验位最后停止位拉高。接收端在起始位下降沿之后每隔 1 位时间采样一次。所谓模拟串口就是用软件精确控制 GPIO 在正确的时间点翻转让对面那个真正的 UART 芯片以为在跟一个标准串口说话。发送相对简单你掌握主动权只要按位定时翻转就行。接收才是真正的难点因为你不知道数据什么时候来必须在起始位下降沿触发中断然后在每一位的中点采样避开边沿抖动。采样点偏早或偏晚累积几个位之后就会采错。STM8S003 上做模拟串口核心资源就三样一个空闲的定时器、两个 GPIO一收一发、以及足够快的中断响应。下面这张表是我在选型时对比的几个关键点资源可选方案我的选择与理由定时器TIM1 / TIM2 / TIM4TIM4 是 8 位基本定时器结构简单、中断向量独立适合做位定时发送引脚任意 GPIO选带外部中断能力的口方便调试时抓波形接收引脚带 EXTI 的 GPIO必须能触发外部中断用于捕获起始位时钟内部 16MHz / 外部晶振内部 RC 有 ±1% 以上偏差115200 下会累积误差建议外部晶振2.2 为什么定时器选 TIM4 而不是 TIM2TIM2 是 16 位通用定时器功能强但它的中断向量和多个功能复用配置起来寄存器更多。TIM4 是 8 位定时器只有一个自动重载寄存器 TIM4_ARR 和一个计数器 TIM4_CNTR预分频也只有 3 位。做位定时只需要每隔固定时间中断一次TIM4 完全够用而且中断服务程序短、响应确定。关键计算假设系统时钟 16MHz预分频设为 1即不分频那定时器计数频率就是 16MHz。要得到 115200 波特率下一位的时间 8.68μs需要计数值 16MHz × 8.68μs ≈ 139。但 TIM4 是 8 位最大 255139 放得下。如果波特率更低比如 9600一位 104μs计数值 1667 就超了必须加预分频。所以预分频要根据波特率动态算不能写死。我一般会写一个宏来算// 系统时钟 16MHz目标波特率 baud // TIM4 计数频率 16MHz / prescaler // 每位的计数值 计数频率 / baud // 要求该值 255且尽量接近整数以减小误差 #define SYS_CLK_HZ 16000000UL #define TIM4_PRESCALER 1UL // 不分频 #define BIT_TICKS(b) ((SYS_CLK_HZ / TIM4_PRESCALER) / (b)) // 115200 时 BIT_TICKS 138.88 - 取 139实际波特率 115107误差 0.08% // 9600 时 BIT_TICKS 1666超过 255必须改预分频提示TIM4 预分频寄存器 TIM4_PSCR 的值是 2 的幂次写 0 表示 1 分频写 1 表示 2 分频以此类推。别直接写 1 以为是 1 分频。2.3 GPIO 配置的两种模式差异发送引脚配成推挽输出这样高低电平都有驱动能力上升沿陡峭。接收引脚配成上拉输入因为 UART 空闲是高电平靠内部上拉维持外部设备拉低时能读到 0。这里有个容易翻车的点STM8S003 的某些引脚上拉电阻是 40kΩ 左右如果对端驱动能力弱或者线长上升沿会变缓采样点就要往后挪一点。我一般接收采样点设在位时间的 0.5 倍处如果波形质量差会调到 0.6 倍。3. 在 IAR 里把发送和接收跑通3.1 发送一个字节的完整时序发送的核心是拉低起始位 → 延时一位 → 依次发 8 位数据 → 拉高停止位。用 TIM4 做位定时每进一次中断就发下一位。下面是我常用的发送实现#include iostm8s003f3.h volatile uint8_t tx_buf[32]; volatile uint8_t tx_len 0; volatile uint8_t tx_idx 0; volatile uint8_t tx_bit 0; volatile uint8_t tx_data 0; // 启动一次发送把数据放进缓冲区 void soft_uart_send(const uint8_t *data, uint8_t len) { while (tx_len ! 0); // 等上一次发完简单粗暴但可靠 for (uint8_t i 0; i len; i) { tx_buf[i] data[i]; } tx_len len; tx_idx 0; tx_bit 0; tx_data tx_buf[0]; // 拉低起始位同时启动 TIM4 TX_PIN_ODR ~TX_PIN_MASK; // 起始位 TIM4_CNTR 0; TIM4_ARR BIT_TICKS(115200) - 1; TIM4_CR1 | 0x01; // 使能定时器 TIM4_IER | 0x01; // 使能更新中断 } // TIM4 更新中断服务程序 #pragma vector TIM4_UPD_OVF_IRQ __interrupt void TIM4_UPD_ISR(void) { TIM4_SR ~0x01; // 清中断标志 if (tx_bit 8) { // 发数据位LSB 先发 if (tx_data 0x01) { TX_PIN_ODR | TX_PIN_MASK; } else { TX_PIN_ODR ~TX_PIN_MASK; } tx_data 1; tx_bit; } else if (tx_bit 8) { // 停止位 TX_PIN_ODR | TX_PIN_MASK; tx_bit; } else { // 一个字节发完看还有没有下一个 tx_idx; if (tx_idx tx_len) { tx_data tx_buf[tx_idx]; tx_bit 0; TX_PIN_ODR ~TX_PIN_MASK; // 下一个起始位 } else { tx_len 0; TIM4_CR1 ~0x01; // 关定时器 TIM4_IER ~0x01; } } }逻辑说明soft_uart_send先把数据拷进缓冲区然后拉低起始位并启动 TIM4。第一次中断发生在起始位结束的时刻此时开始发 bit0。之后每次中断发一位第 9 次中断发停止位第 10 次中断判断是否还有下一个字节。参数上BIT_TICKS(115200)算出 139写入TIM4_ARR时要减 1因为计数器从 0 数到 ARR 共 ARR1 个周期。注意while (tx_len ! 0)这种忙等在实际项目里要加超时否则一旦中断没进来会死循环。我一般加一个计数器超过一定次数就强制清零。3.2 接收端如何捕获起始位并采样接收比发送麻烦。思路是接收引脚配成外部中断下降沿触发。一旦触发说明起始位来了立刻启动 TIM4第一次中断设在1.5 位时间后这样正好落在 bit0 的中点。之后每隔 1 位时间采样一次。volatile uint8_t rx_data 0; volatile uint8_t rx_bit 0; volatile uint8_t rx_byte 0; volatile uint8_t rx_ready 0; // 接收引脚外部中断下降沿触发 #pragma vector EXTI_PORT_IRQ __interrupt void EXTI_ISR(void) { // 清中断标志具体寄存器名按实际端口改 EXTI_SR1 ~RX_PIN_MASK; if (rx_bit 0 rx_ready 0) { // 检测到起始位启动定时器第一次延时 1.5 位 TIM4_CNTR 0; TIM4_ARR (BIT_TICKS(115200) * 3 / 2) - 1; TIM4_CR1 | 0x01; TIM4_IER | 0x01; rx_bit 1; } } // TIM4 中断里同时处理接收采样 // 注意发送和接收如果共用一个定时器需要状态机区分逻辑说明起始位下降沿触发外部中断后把 TIM4 的 ARR 设成 1.5 位时间这样第一次更新中断正好在 bit0 中点。采样到 bit0 后把 ARR 改回 1 位时间后续每次中断采样一位。采满 8 位后读停止位置rx_ready标志。参数上BIT_TICKS(115200) * 3 / 2要保证不溢出 8 位。139 × 3 / 2 208放得下。如果波特率更低比如 96001666 × 3 / 2 就超了必须用预分频把计数频率降下来。3.3 IAR 工程里必须检查的三个编译选项IAR 的优化等级会直接影响中断响应时间。我遇到过-Ohs高优化下模拟串口发送正常、接收丢字节的情况原因是编译器把中断里的某些变量优化到寄存器导致采样时序偏移。建议第一中断服务程序加__interrupt关键字IAR 会自动保存和恢复现场不要手动写汇编。第二优化等级先用-On或-Ol等时序稳定后再尝试提高。第三把中断里用到的全局变量加volatile否则编译器可能认为它没变过。另外IAR 的链接器配置文件里要确认中断向量表正确映射。STM8S003 的 TIM4 更新中断向量地址是固定的如果你用了自定义的.icf文件检查TIM4_UPD_OVF_IRQ是否指向了正确的地址。4. 避坑与常见问题排查4.1 发送正常但接收全是乱码现象用逻辑分析仪看发送波形完全正确但接收端读出来的字节跟发的不一样或者全是 0xFF。原因采样点偏了。最常见的是第一次中断设在 1.0 位时间而不是 1.5 位导致采到 bit0 的边沿而不是中点。另一个原因是外部中断响应延迟STM8S003 从中断触发到进 ISR 有十几个周期的延迟如果波特率高这个延迟不能忽略。解决把第一次采样的 ARR 值从BIT_TICKS改成BIT_TICKS * 3 / 2让采样点落在中点。如果还不行用示波器抓接收引脚波形量一下起始位下降沿到第一个数据位中点的实际时间反推需要的计数值。4.2 高波特率下丢字节现象115200 时连续发多个字节接收端只能收到第一个后面的全丢。原因发送和接收共用一个 TIM4中断里既要处理发送又要处理接收ISR 太长导致下一个字节的起始位来了还没处理完。或者soft_uart_send里的忙等没有及时退出阻塞了接收中断。解决发送和接收用两个独立的定时器比如 TIM4 做发送、TIM2 做接收。如果定时器不够把 ISR 精简到最少操作只做 GPIO 翻转和计数器更新其他逻辑放到主循环。另外发送缓冲区不要用忙等改成状态机发完一个字节自动发下一个。4.3 内部时钟下波特率误差累积现象用内部 16MHz RC 振荡器时短帧正常长帧超过 10 个字节后面几个字节出错。原因STM8S003 内部 RC 精度典型值 ±1%115200 下每位误差 1% 就是 86ns10 位累积 860ns接近一位时间的 10%。如果对端也有误差累积起来就采错。解决换外部晶振或者把波特率降到 9600。如果必须用内部时钟在协议层加校验和重传。我一般会在初始化时用 TIM1 的输入捕获测一下实际时钟频率动态修正BIT_TICKS的值。4.4 中断优先级冲突导致时序抖动现象系统里还有其他中断比如 ADC、定时器时模拟串口偶尔出错单独跑没问题。原因STM8S003 的中断优先级由 ITC 寄存器控制如果其他中断优先级比 TIM4 高会打断位定时 ISR导致某一位时间变长。解决把 TIM4 和接收外部中断的优先级设成最高。STM8 的中断优先级通过ITC_SPR寄存器配置具体写法// 把 TIM4 更新中断设为最高优先级软件优先级 3 ITC_SPR5 (ITC_SPR5 0x3F) | 0xC0; // 具体位域按手册调整注意STM8 的中断优先级分硬件和软件两级硬件优先级固定软件优先级可调。模拟串口对时序敏感建议把相关中断都设成最高软件优先级。4.5 停止位被误判为下一个起始位现象接收完一个字节后紧接着收到一个 0x00 或 0xFF 的假字节。原因停止位是高电平但如果接收引脚在停止位期间被干扰拉低外部中断会误触发当成新的起始位。解决在接收完停止位后暂时关闭外部中断等 1 到 2 位时间后再打开。或者用状态机判断只有当前不在接收状态时才响应起始位中断。我一般会在 ISR 里加一个rx_busy标志接收期间忽略新的下降沿。5. 用状态机把模拟串口做成可复用的模块前面几章的代码是能跑的版本但真正放到项目里你会发现发送和接收互相干扰、中断里逻辑太杂、波特率改一下就要动好几处。我的做法是把它重构成一个基于状态机的模块发送和接收各自独立共用一个定时器但分时复用。核心思路TIM4 的更新中断频率固定为 1 位时间ISR 里只做一件事——根据当前状态决定是发送还是接收然后翻转 GPIO 或采样。状态机用两个变量表示tx_state和rx_state。发送状态有 IDLE、START、DATA、STOP接收状态有 IDLE、START、DATA、STOP。typedef enum { UART_IDLE 0, UART_START, UART_DATA, UART_STOP } uart_state_t; volatile uart_state_t tx_state UART_IDLE; volatile uart_state_t rx_state UART_IDLE; volatile uint8_t tx_shift 0; volatile uint8_t rx_shift 0; volatile uint8_t bit_cnt 0; #pragma vector TIM4_UPD_OVF_IRQ __interrupt void TIM4_UPD_ISR(void) { TIM4_SR ~0x01; // 发送状态机 switch (tx_state) { case UART_START: TX_PIN_ODR ~TX_PIN_MASK; tx_state UART_DATA; bit_cnt 0; break; case UART_DATA: if (tx_shift 0x01) TX_PIN_ODR | TX_PIN_MASK; else TX_PIN_ODR ~TX_PIN_MASK; tx_shift 1; if (bit_cnt 8) tx_state UART_STOP; break; case UART_STOP: TX_PIN_ODR | TX_PIN_MASK; tx_state UART_IDLE; break; default: break; } // 接收状态机 switch (rx_state) { case UART_START: // 第一次中断在 1.5 位采样 bit0 rx_shift 0; bit_cnt 0; rx_state UART_DATA; // 注意这里不 break直接落到 DATA 采样 case UART_DATA: rx_shift 1; if (RX_PIN_IDR RX_PIN_MASK) rx_shift | 0x80; if (bit_cnt 8) rx_state UART_STOP; break; case UART_STOP: rx_ready 1; rx_state UART_IDLE; break; default: break; } }逻辑说明发送状态机在 START 状态拉低起始位DATA 状态逐位输出STOP 状态拉高。接收状态机在 START 状态做第一次采样因为外部中断已经把 ARR 设成 1.5 位然后落到 DATA 状态继续采样。这样 ISR 里没有循环、没有延时执行时间固定不会因为分支不同导致时序抖动。参数上bit_cnt是 8 位计数器tx_shift和rx_shift是移位寄存器。波特率切换只需要改TIM4_ARR的值状态机逻辑不变。如果要支持校验位在 DATA 和 STOP 之间加一个 PARITY 状态即可。这个模块我用了好几个项目从 9600 到 115200 都跑过。最深的教训是别在中断里做任何可能阻塞的事包括调用库函数、访问 Flash、甚至读一个可能被其他中断修改的变量。有一次我在 ISR 里加了一句printf调试结果整个时序全乱查了半天才发现是串口打印本身又触发了中断。后来我养成习惯ISR 里只碰寄存器和 volatile 变量调试信息全部攒到主循环再输出。希望帮到你。本文还有配套的精品资源点击获取