STC32G定时器与串口函数库设计:从寄存器到工程实践
2026/9/12 11:22:06 网站建设 项目流程

简介:STC32G系列单片机定时器及串行通信接口函数库是一份面向STC32G开发者的非官方函数库压缩包,覆盖GPIO、定时器、串口通信、中断与I2C等常用模块;整套代码采用宏函数与接口函数结合的方式,寄存器操作以宏完成,复杂功能则封装成函数,注释清楚、调用状态与声明分离,方便直接复制粘贴使用。例如一行“Uart1_Init(VBAUD_8BITS,G1,1,9600)”即可完成串口1的初始化,再用 Uart1_SendString(“Hello world”) 即可发送字符串,显著降低新手配置寄存器的门槛。压缩包共76个文件,约12.97MB,包含C源文件、H头文件、Keil工程与库文件、HEX烧录文件、STC32G手册PDF说明及列表、中间文件等,目录结构按Objects、Listings、Lib、Proj分层,便于按模块调用和二次移植;其中还附带示例工程,可快速验证定时器和串口收发。目前已有821人浏览学习,适合需要快速搭建串口、定时器基础外设的嵌入式开发者作为参考。

1. 为什么要自己给STC32G写定时器与串口函数库

STC32G系列虽然挂着“32位”名头,核心仍是增强型8051指令集,寄存器布局比传统51丰富很多,定时器、串口的配置选项也更多。很多工程师第一反应是直接用STC提供的ISP烧录工具附带的库,或依赖厂商例程里的STC32G_LIB,但这类库为了照顾全系列型号,函数体里充满了条件编译和兼容层,最终编译完的代码体积、中断响应时间都不好控制。非官方函数库的价值在于:把最常用的定时器延时、波特率计算、帧接收逻辑整理成独立文件,按项目裁剪,直接面对SFR操作,想改本振频率或者换串口映射口时,改一个宏就行,不用翻几十页数据手册查寄存器初始值。

这个库解决的是嵌入式开发里两个最高频的需求:定时器中断做系统时基,串口收发做数据交换。适合三类人:刚从51过渡到STC32G、对C251编译器还不熟的人;产品里同时要跑多个定时器和多路串口,需要一个清晰调度壳的人;以及被官方库“黑盒”坑过,想用寄存器级代码确认每个位状态的人。下面这套方案不是官方库的替代品,而是提供一种更轻的工程组织方式,所有代码在STC32G系列上可编译,核心逻辑不依赖具体型号,换成STC8H或STC32F也只需要改时钟宏和映射位。

2. 定时器模块:初始化、中断回调与滴答时钟的正确姿势

STC32G一共给了5个定时器:T0、T1、T2、T3、T4,每个都能工作在定时或计数模式,T0/T1还保留了传统51的12T分频选项。真正写库的时候,不能每个定时器都做成一套并列API,那样会比官方库更臃肿。常见做法是抽出两个概念:一个是系统滴答(System Tick)定时器,固定用中断产生毫秒心跳;另一个是捕获/计数定时器,供外部脉冲测速、PWM等场景使用。

2.1 定时器资源分配和频率计算

分配定时器时,先看波特率发生器是否要占用定时器。STC32G的串口1/2可以用独立波特率发生器S1BRG/S2BRG,不占T0-T4,但如果要兼容老代码或者使用传统T1做波特率,定时器分配会被锁死。下表是常用资源分配清单:

定时器典型用途关键寄存器是否建议用库封装
T01ms系统滴答TMOD, AUXR, TL0/TH0是,作为全局tick
T1外部脉冲计数TMOD, TF1, TL1/TH1是,测频率方向
T2多路软定时器T2MOD, TL2/TH2是,辅助T0扩展
T3/T4串口接收超时或延时T3T4MOD, TL3/TH3视项目需要

计算定时初值时,核心公式是:初值 = 65536 - (定时时间 [s] × 定时器时钟频率 [Hz])。如果时钟24MHz、T0使用12T分频,则计数频率为2MHz,1ms定时需要计数2000次,初值就是63536,即0xF830。放在代码里比手动算要稳,因为改个主频不用重新心算。

2.2 最小可用的定时器初始化代码

以下代码把T0初始化为12T模式的16位自动重装定时器,中断周期1ms,同时把系统滴答变量清零:

#define FOSC 24000000UL volatile unsigned long sys_tick = 0; void Timer0_Init(unsigned int time_us) { unsigned long tmp; AUXR &= 0x7F; // T0x12 = 0,选择12T模式 TMOD = (TMOD & 0xF0) | 0x00; // T0工作在模式0,16位自动重装 tmp = (unsigned long)time_us * (FOSC / 12) / 1000000UL; tmp = 65536UL - tmp; TL0 = (unsigned char)(tmp & 0xFF); TH0 = (unsigned char)(tmp >> 8); TF0 = 0; // 清除中断标志 TR0 = 1; // 启动定时器0 ET0 = 1; // 使能T0中断 EA = 1; // 总中断使能 sys_tick = 0; }

这段代码里,AUXR &= 0x7FTMOD的掩码操作是为了不影响T1的现有配置。time_us是定时周期微秒值,但受16位计数上限限制,24MHz时钟下12T模式最大定时约32.7ms,如果传入超过这个范围的数,初值会溢出,结果就是定时不准。调用时传1000表示1ms,这是库对外统一参数单位,内部算成计数初值。记得把sys_tick声明为volatile,否则编译器优化后主循环会一直读到旧值。

2.3 中断服务函数里只做标记和累加

T0中断服务函数建议只维护系统滴答,并把需要周期性处理的任务放到主循环中判断。例如:

void Timer0_ISR(void) interrupt 1 { sys_tick++; // 在这里只放时间开销可预测的代码,禁止调用串口发送 }

如果某个任务需要每隔100ms执行一次,不要写在中断里,而是在主循环判断sys_tick % 100 == 0。这样能避免中断嵌套过长导致串口丢字节,也能让库的使用者不用理解复杂的中断优先级配置。定时器回调机制要克制,当一个定时器对应多个软定时任务时,用结构体数组分配:

typedef struct { unsigned long timeout; bit active; void (*callback)(void); } soft_timer_t; soft_timer_t soft_timers[4]; void SoftTimer_Check(void) { unsigned char i; for (i = 0; i < 4; i++) { if (soft_timers[i].active) { if (sys_tick >= soft_timers[i].timeout) { soft_timers[i].active = 0; soft_timers[i].callback(); } } } }

这种软定时器方式的优点是不占用额外硬件定时器,回调函数在主循环里执行,不需要考虑中断现场保存。新手容易犯的错误是把回调函数直接放在定时器中断里,一旦回调里有delay或串口等待发送,中断函数就会被拖长,外部脉冲计数和串口接收同时崩溃。

2.4 定时器相关的“状态位”和异步坑

STC32G访问部分扩展SFR时,需要先置位P_SW2的访问控制位,比如操作EEPROM时,定时器中断里直接读忙标志可能读到0,必须在主循环里轮询。另外,修改定时器预分频寄存器的瞬间,计数可能跳变,所以稳定工程里不太建议在运行中切换分频值。如果需要既保留T0做滴答,又要测量外部信号宽度,正确的做法是把T1配置成捕获模式,而不是让T0分时复用。

3. 串行通信模块:波特率发生、字节收发与Modbus帧同步

STC32G的串口1和串口2都可以用独立波特率发生器,也可以退回传统T1方案。我推荐独立波特率,因为这样定时器资源不被占用,尤其是在同时需要多路定时器、多路串口的场景下,这个优势很明显。串口库封装的核心是:波特率计算、字节收发、帧结束判断三件事。

3.1 使用S1BRG的波特率计算

独立波特率发生器的优势,是把波特率配置从定时器里剥离出来。S1BRG是两个8位寄存器组成的16位重装值,计算公式是:

S1BRG = (FOSC / (4 * baud)) - 1

当主频24MHz、波特率115200时,24000000 / (4 * 115200) = 52.08,取整后S1BRG是51,实际波特率约115942,误差约0.6%。这个误差在UART可容忍范围内。误差超过2%时就要考虑换主频或换波特率,或者用非整数波特率发生器修正。

3.2 串口1初始化代码

#define UART1_BAUD 115200UL void UART1_Init(void) { unsigned int brg; S1CON = 0x50; // 模式1:8位可变波特率,REN=1使能接收 brg = (unsigned int)(FOSC / 4UL / UART1_BAUD - 1UL); S1BRGL = (unsigned char)(brg & 0xFF); S1BRGH = (unsigned char)(brg >> 8); ES = 1; // 使能串口1中断 EA = 1; TI = 0; RI = 0; }

S1CON=0x50,即SM0=0,SM1=1,REN=1,这是8位UART模式;REN=1让接收器持续检测RXD上的起始位。S1BRGLS1BRGH分开写,和很多51库函数里直接给SBUF赋初值不同,这里明确把波特率发生器和串口数据缓冲区分开,便于后续调试时读取当前配置。

3.3 发送函数的临界区处理

发送字节最简单的写法是等待TI位置1,但这在中断驱动的通信里会造成阻塞。实际库函数可以做成两个版本:阻塞版用于初始化阶段打印调试信息,非阻塞版用于正常数据流。阻塞版如下:

void UART1_SendByte(unsigned char dat) { SBUF = dat; while (!TI); // 等待发送完成 TI = 0; }

如果是在RTOS或多任务环境里,等待TI必须加超时,否则波特率配置错误时程序会死锁。我一般会在库的调试版本里加一个计数:

unsigned int uart_timeout; while (!TI) { if (++uart_timeout > 100000) { uart_timeout = 0; break; } } TI = 0;

注意:TI位置1后必须软件清零,这个位和RI共用一个中断标志,不清理会导致中断持续触发。

3.4 接收帧:用定时器做超时切割

串口接收如果只是单字节中断,最简单,但实际项目中更常见的是接收一帧数据,例如Modbus RTU、自绘协议。Modbus RTU规定帧间隔是3.5个字符时间,在115200波特率下大约0.3ms,9600波特率下约4ms。如果用纯延时确认帧结束,主循环会卡住。常见做法是把定时器超时和串口接收组合起来:

#define RX_BUF_SIZE 64 #define FRAME_TIMEOUT_MS 5 unsigned char rx_buf[RX_BUF_SIZE]; unsigned char rx_index = 0; bit frame_ready = 0; unsigned char frame_timer = 0; void Timer0_ISR(void) interrupt 1 { sys_tick++; if (rx_index > 0) { if (++frame_timer >= FRAME_TIMEOUT_MS) { frame_timer = 0; frame_ready = 1; // 超过5ms没有新数据,认为帧结束 } } } void UART1_ISR(void) interrupt 4 { if (RI) { RI = 0; if (rx_index < RX_BUF_SIZE) { rx_buf[rx_index++] = SBUF; frame_timer = 0; // 有数据连续来到,重置超时 } } }

这里把帧超时和系统滴答共用T0中断,FRAME_TIMEOUT_MS要按实际波特率调整。如果波特率是115200,一帧Modbus报文通常只有8个字节,传输时间不到1ms,所以5ms足够区分前后两帧;如果是9600波特率且帧很长,5ms可能把一帧切断,需要调大。关键逻辑是:rx_index > 0才启动超时计数,避免空闲时定时器一直清标记浪费时间。

3.5 环形缓冲区解决丢字节

串口接收中断里如果处理逻辑太重,下一个字节到来时RI被清零但字节没读走就会丢。给串口接收加一个简单的环形缓冲区是性价比很高的做法:

#define RING_SIZE 128 unsigned char ring_buf[RING_SIZE]; volatile unsigned char ring_head, ring_tail; void Ring_Push(unsigned char dat) { unsigned char next = (ring_head + 1) % RING_SIZE; if (next != ring_tail) // 区分满状态 { ring_buf[ring_head] = dat; ring_head = next; } } unsigned char Ring_Pop(unsigned char *dat) { if (ring_head == ring_tail) return 0; *dat = ring_buf[ring_tail]; ring_tail = (ring_tail + 1) % RING_SIZE; return 1; }

在串口中断里调用Ring_Push(SBUF),主循环里用Ring_Pop取数据做协议解析。这样即使主循环被定时器回调或其他任务阻塞,串口中断也能把新字节先存起来。环形缓冲区的缺点是会丢满,所以发送一帧时如果缓冲区被填满,接收侧要预留重置逻辑。

4. 函数库的模块化封装:配置头文件、API设计与Keil工程集成

前面的定时器和串口代码是零散的,要变成“函数库”,需要把硬件相关配置集中到一个头文件,把驱动接口独立到一对.c/.h文件里。最终使用者只要改一个宏,就能从串口1换到串口2,或者从24MHz主频换到11.0592MHz,不需要翻驱动文件。

4.1 集中式配置头文件

工程根目录下放一个board.h,内容是硬件选型相关配置:

#ifndef BOARD_H #define BOARD_H #define FOSC 24000000UL // 主频 #define UART1_ENABLE 1 #define UART1_BAUD 115200UL #define TIMER0_ENABLE 1 #define TIMER0_TICK_US 1000UL #endif

驱动源码里不要出现裸数字,全部通过这个头文件取。例如Timer0_Init(TIMER0_TICK_US)UART1_Init内部读UART1_BAUD。这样换主频时只要改一行宏,波波特率寄存器重装值会自动重算,避免出现代码里写死初值、改电路板晶振后忘记同步的隐患。

4.2 函数API怎么设计才不会越用越乱

非官方库容易被批“不好用”,一大原因是API风格不统一。定时器接口建议做成:

函数名作用参数说明
Timer0_Init(time_us)初始化T0并启动中断定时周期,单位微秒
Timer0_SetCallback(cb)注册主循环周期任务回调函数指针
Timer0_GetTick()获取系统滴答值返回sys_tick
SoftTimer_Start(slot, timeout_ms, cb)启动软定时器定时器槽位、超时值、回调

串口接口则分收发两个方向:

void UART1_SendBuffer(unsigned char *buf, unsigned char len); void UART1_SendString(char *str); void UART1_RxInit(void); bit UART1_RxFrameReady(void); unsigned char UART1_RxFrameLen(void); void UART1_RxClear(void);

回调注册放在初始化函数之后,不要放在定时器中断里。如果某个定时器回调里用了串口发送,而串口发送又是阻塞式的,整个系统会被一条while(!TI)拖慢,这个细节在调试时很难发现。

4.3 把库编成LIB还是一个文件复制进工程

常见做法是直接把驱动.c文件加入Keil C251工程,而不是预编译成.LIB文件。原因很简单:.LIB文件里如果包含中断向量,一旦芯片型号改动,中断向量表可能错位,排查难度陡增。用源文件方式还能让工程师看到每个寄存器的操作,学习成本低。工程里把所有驱动文件放进一个driver目录,并在Keil里添加对应Include路径,需要改动时直接打开头文件。

如果确实想生成LIB库供其他团队引用,注意在C251编译器里要勾选“Generate Library”而不是“Executable”,并且中断函数必须在源码中定义,不能放在LIB里,否则用户无法感知中断向量。

4.4 编译器优化陷阱

STC32G的Keil C251编译器对没有副作用的外设访问可能会做优化。比如下面这段:

void UART1_SendByte(unsigned char dat) { SBUF = dat; while (!TI); TI = 0; }

如果编译器设置为高优化级别,while(!TI)可能被优化成死循环,因为TI在编译器看来没有被任何代码修改。解决办法是把TI声明成volatile相关位,或者把串口中断和收发相关函数放在驱动文件里并设置为低优化。我一般会在Options里对驱动文件单独选择Level 0优化,业务代码用Level 3,既保证外设访问正确,又不牺牲整体代码密度。

5. 用库组合一个脉冲测速的例子:T0外部计数+T1滴答+串口上报

最后给一个完整可跑的实用技巧:把T0配成外部计数模式,用来积累霍尔传感器或光电编码器的脉冲;同时用T1产生1ms滴答(不占用串口,因为串口已用S1BRG);每秒钟把脉冲数换算成转速,通过串口发到上位机。这个例子能同时验证定时器计数、定时器中断、串口帧发送三个模块,也是电机测速最常见的需求。

#define PULSES_PER_REV 20 // 电机每转一圈输出20个脉冲 volatile unsigned long sys_tick = 0; volatile unsigned char one_sec_flag = 0; unsigned int pulse_count = 0; unsigned char tick_count = 0; // T0外部计数模式初始化 void Timer0_CounterInit(void) { TMOD = (TMOD & 0xF0) | 0x04; // C/T = 1,计数器模式,T0引脚输入脉冲 AUXR &= 0x7F; // 12T模式,外部脉冲直接计数 TH0 = 0; TL0 = 0; TR0 = 1; // 启动计数,没有中断 } // T1定时器产生1ms滴答 void Timer1_Init(void) { AUXR &= 0xBF; // T1x12 = 0,12T模式 TMOD = (TMOD & 0x0F) | 0x20; // T1模式2,8位自动重装 TH1 = (unsigned char)(256UL - (FOSC / 12 / 1000UL)); TL1 = TH1; ET1 = 1; TR1 = 1; } void Timer1_ISR(void) interrupt 3 { if (++tick_count >= 1000) { tick_count = 0; one_sec_flag = 1; } } void main(void) { char speed_str[24]; unsigned int speed_rpm; UART1_Init(); Timer0_CounterInit(); Timer1_Init(); EA = 1; while (1) { if (one_sec_flag) { one_sec_flag = 0; speed_rpm = (unsigned int)(TH0 * 256UL + TL0) / PULSES_PER_REV; TH0 = 0; TL0 = 0; // 重新计数,注意此时读值和清零之间不能太久 UART1_SendString("RPM:"); // 转换并发送,略 UART1_SendByte((speed_rpm >> 8) & 0xFF); UART1_SendByte(speed_rpm & 0xFF); } } }

这里的测速逻辑是每秒读取T0计数并清零,所以转速分辨率是1秒内的脉冲个数。如果追求更高实时性,可以把T1滴答改成10ms,然后对差值做一阶移动平均。要注意的是:T0和T1共用一个TMOD寄存器,汇报速度前清零T0计数时要先读后清,不能清完再读,否则会少计几个脉冲。这个细节走对后,整个测速和串口上报逻辑就能稳定跑在24MHz主频下,适合直接放进小批量产品或竞赛原型里使用。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询