STC32G定时器与串口函数库设计:从寄存器到工程实践

STC32G定时器与串口函数库设计:从寄存器到工程实践 简介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是作为全局tickT1外部脉冲计数TMOD, TF1, TL1/TH1是测频率方向T2多路软定时器T2MOD, TL2/TH2是辅助T0扩展T3/T4串口接收超时或延时T3T4MOD, TL3/TH3视项目需要计算定时初值时核心公式是初值 65536 - (定时时间 [s] × 定时器时钟频率 [Hz])。如果时钟24MHz、T0使用12T分频则计数频率为2MHz1ms定时需要计数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工作在模式016位自动重装 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 0x7F和TMOD的掩码操作是为了不影响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; // 模式18位可变波特率REN1使能接收 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; }S1CON0x50即SM00,SM11,REN1这是8位UART模式REN1让接收器持续检测RXD上的起始位。S1BRGL和S1BRGH分开写和很多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.3ms9600波特率下约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_tickSoftTimer_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 012T模式 TMOD (TMOD 0x0F) | 0x20; // T1模式28位自动重装 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主频下适合直接放进小批量产品或竞赛原型里使用。本文还有配套的精品资源点击获取