写在前面
这个模板是我在刷题的过程中总结出来的,如您觉得有改进之处或者错误欢迎指出. 另外,主播的英语不好,函数名都是乱取的(
总的来说,模板由两部分组成. 一部分使用一个定时器负责当作时钟,可以定时调用函数, 而另一部分就是实现功能的函数.
时钟部分
以下均以
Timer0为例.
作为计时的部分,我们首先需要确认定时器的最小时间是什么. 一般选择1ms, 这样,1ms整数倍的时间都能够在中断函数被表示. (主要是方便数码管刷新)
1 | void Timer0_Init(void) //1毫秒@12.000MHz |
那如何隔一段时间调用函数呢?我们需要一个计数器来记录时间过去了多久.
若定义一个cnt, 每次调用中断函数都过去1ms的时间, cnt都会计一次数,这样当过去的时候,if条件成立,调用函数. 这样就实现了每过一段任意的时间调用一次函数.
1 | void Timer0_Routine(void) interrupt 1 { |
接下来要解决的问题就是:在这个中断函数中应该干什么呢?
PCA
有时候,STC15F自带的三个定时器不够用. 这时,可以选择CCP/PCA功能实现16位定时器. 此处参考手册11.5章节给出的测试程序.
与Timer0/1/2类似,PCA也需要初始化. 具体的,我们需要告诉芯片,PCA要工作在计时模式、用什么时钟源和计数器的初始值是什么,并清除溢出标志与使能中断.
但与Timer0/1/2有不同的是,PCA不是计数器溢出后就触发中断,而是满足给定的判定条件才触发中断,所以我们还需要设置判定条件.
. 传统定时器(Timer 0/1):玩的是“溢出”:传统定时器就像一个水桶。
- 原理:你要让它计时 10 毫秒,你就提前给它装上“底水”(把初值设为
65536 - 10毫秒的计数值)。然后它开始一滴滴接水,等水接满(达到 65536)溢出的一瞬间,触发中断。- 特点:每次水漏光了(进中断了),你都得重新给它打一次“底水”。
- PCA 定时器:玩的是“定闹钟”
PCA 模块的底层逻辑完全不同。PCA 内部只有一个全局的、永不停歇的“时钟主表”(由寄存器CH和CL组成)。
- 原理:这个主表从 0 走到 65535,满了就自己回到 0 继续走,你通常永远不去修改它。
- 怎么定时:PCA 下面挂着好几个子模块(模块0、模块1、模块2…)。每个子模块都有自己的“比较寄存器”(比如代码里的
CCAP0H和CCAP0L),这就相当于“闹钟的指针”。- 触发条件:当全局主表(
CH/CL)的时间,刚好走到你设定的闹钟时间(CCAP0H/CL)时,两者数据匹配,触发中断。
CMOD为0x00对应着12分时模式,对应蓝桥杯的要求,12MHZ/12=1MHZ->1us/time,所以对1ms对应1000cnt,可以使用win10自带计算器得出,对应二进制为0x03E8. 对应CCAP0H/L初始值为
0x03和0xE8. 而为了方便下一次更新判定条件,建议设定一个变量记录当前的判定条件. 这些不用死记,手册有~
1 | unsigned value=1000; |
那么,中断时需要处理什么呢?首先需要先清除溢出位. 其次就是需要动态设置判定条件——也就是把新的value赋进去. 同时,因为PCA的CL和CH溢出的条件和value溢出的条件是一样的,所以他们会同步自动归0,不用手动赋值. Amazing啊
1 | void PCA_Isr() interrupt 7 { |
输入与输出
按键扫描
这部分 分为扫描矩阵键盘与处理扫描函数返回值两个部分.
第一部分,我们定义一个函数用来扫描矩阵键盘,返回当前键码值. 函数原型为:
unsigned char ScanMatrix(void);
因为蓝桥杯要用到的按键不多,所以直接对一个引脚进行赋值/读取即可.
例如我要看S7按键是否被按下,我只需要使得COL中只有COL1为0(防止其他按键干扰),然后去判断ROW1是否为0就行了(若按下,则强制ROW1和COL1电位相等,为低电平).
同样,如果要判定是否有两个按键同时被按下的话,可以把他们对应的引脚值暂存起来,最后一起判定是否都为低电平.
1 | //existing code.. |
这样,ScanMatrix的功能就是,如果有按键被按下返回键码值,否则返回0.
那么,怎么处理返回值呢?
一般是在按键松开时触发,所以只需要定义一个static的变量,存上一次扫描的键码值. 如果这次的键码值为0,而上一次的键码值不为0,就说明按键松开了.
但这里处理双键有一个小细节需要注意. 松手时可能会出现双键不同时松开的情况,例如一起按下s5与s8,最后先松开s5,这样preKey是58,而key是8不是0,与单键不一样,需要特判. 此外,还需要保证后松开的s8不会触发单键. 所以需要在触发双键时用一个变量标记状态,使得后一次单键不触发.
ps: 因为这里每50ms才扫描一次按键,所以不用消抖.
1 | void ProcessKey(){ |
此外,长按也可以在这里处理. 不同的是,它不用在按键松开的条件下处理,所以在if外写. 同样需要定义一个计数变量用来记录长按了多久,在大于一定值时触发.
1 | void ProcessKey(){ |
数码管显示
模板中,时钟最小有1ms,这已经很小了,没必要再在中断里调用循环了,直接把中断当循环用就行了.
so在中断里面需要调用一个函数,它的功能是更新指定位置的数码管. 与之配套的,需要一个数组存储显示的数据.
1 | unsigned char segData[8] = {0, 1, 2, 3, 4, 5, 6, 7}; |
而在中断之中,需要用一个变量指明该更新哪一位数码管
1 | void Timer0_Routine() interrupt 1 { |
主函数如何写?
比如说我们有一个函数Funtion()用来获取外设数据与进行逻辑计算. 那是在中断中调用好呢,还是在主循环调用好呢?后者更好. 因为前者会导致函数阻塞中断,会导致中断时间不准确. 比如OneWire的通讯时间就需要几十毫秒.
所以我们在中断中需要做的是操控一个标志变量,让主函数里的循环去读取这个变量观察是否运行.
下面实现了每100ms运行一次Funtion.
1 | volatile bit runFlag=0; |
到这里,基本模板的部分结束了. 在这个模板之上调用封装好的外设函数与数据处理等函数完全可以应付蓝桥杯了. 下面的外设部分本意是我个人复习之用,所以可能解析写的不是很详细.
外设
以下设计通信协议的内容均使用蓝桥杯官方数据包里的参考代码.
串口
发送
这部分比较简单,直接给出代码:
有个小技巧是,可以使用占位符,可以方便地使用字符数组发送数据.
1 | void Transmit(char * p) { |
接收
对于串口单个数据的读取来说,并不难. 难的是多个数据的读取与发送,也就是字符串的匹配与异常处理. 这部分很难总结,下面以一个具体的题目为例.
比如说,我需要定义一个这样的命令:P:A,V:B;:
在数码管第A个位置显示B. 若B大于10则向后移位填入, 如果超出范围则把超出部分从头填入. 如果设置成功,串口返回"OJBK",反之返回"ERROR". 如果B是'?',那么返回A位置的数字.
一般不考虑把字符串暂存起来/用很多if-else,这样会浪费空间/很难维护.
这里考虑使用有限状态机. 简单来说,有限状态机就是一个“记录当前走到哪一步,并根据下一个输入决定下一步去哪”的逻辑模型. 这种机制最大的好处是:它永远知道自己现在处于哪一步,绝不会错乱。一旦遇到不符合预期的字符,直接把状态重置为 0,容错能力极强.
状态 0(IDLE / 找 P): 保安在门口打瞌睡。
- 如果收到
P-> 保安醒了,进入状态 1。- 收到其他 -> 继续打瞌睡(丢弃数据,保持状态 0)。
状态 1(等冒号 1): 保安看到 P 了,等着验证身份。
- 如果收到
:-> 验证通过,进入状态 2。- 收到其他 -> 验证失败,报错,踹回状态 0。
状态 2(读 A): 身份没问题,开始记下位置 A。
- 如果是
数字-> 存入变量 A,保持在状态 2。- 如果是
,-> 位置输入完毕,进入状态 3。- 收到其他 -> 格式错误,踹回状态 0。
状态 3(找 V): … 以此类推,直到找到最后的
;和:。
首先我们进行状态拆分,观察P:A,V:B;有哪些状态:
| 状态 | 描述 |
|---|---|
| 状态0 | 等待输入; |
| 状态1 | 位置指示符,等待位置输入; |
| 状态2 | 位置输入; |
| 状态3 | 分隔符; |
| 状态4 | 数值指示符,等待数值输入; |
| 状态5 | 数值输入; |
| 状态6 | 结束符. |
接下来,在中断函数里,我们根据当前的状态来判断该做什么.
这里主要有以下几个点需要注意:不要在串口中断里发送数据,会阻塞导致主时钟计数不准;需要在时钟中断添加一些超时处理,防止非常规的数据阻塞串口.
send作为发送的标志变量,默认是114意味着不要发送. 若触发查询功能,他作为指示位置的变量小于8,所以可以用大于8的其他数字指示要发送的其他功能,比如"OJBK"与"ERROR",这样又扣出来一点存储!
1 | unsigned char cs=0; |
别忘了在主函数发送数据
1 | if(send != 114) { |
添加超时处理
1 | static unsigned char csTimeout = 0; |
E2PROM 持久化存储
IIC函数原型:
1 | void I2CStart(void); |
读取
根据时序图,我们需要依次进行如下操作:
1 | unsigned char ReadData(unsigned char addr) { |
存储
根据时序图,我们需要依次进行如下操作:
注意存储操作之间最好要间隔10ms,防止存储失败.
1 | void SaveData(unsigned char addr,unsigned char dat) { |
Gemini老师让我加这一段:
I2C 读取数据就像是食堂打饭。
- ACK:你对阿姨说“再来一勺”,阿姨勺子不收回去,继续给你盛。
- NACK:你赶紧摆手说“够了够了”,阿姨这才把勺子收回去,关上菜盆盖子(释放总线)。
- Stop:你端着盘子走人。
如果你不摆手说“够了”(NACK),阿姨的勺子一直横在菜盆里,你就没法关盖子走人。
DS1302 时间存储
函数原型:
1 | void Write_Ds1302_Byte( unsigned char address,unsigned char dat ); |
读取
根据Table 3: Register Address/Definition:
对于Second,BIT6-BIT4存储十位,BIT3-BIT0存储个位,所以需要把BIT6-BIT4取出来乘以10再加低四位.
对于Minute,BIT6-BIT4存储十位,BIT3-BIT0存储个位,所以需要把BIT6-BIT4取出来乘以10再加第四位.
对于比较烦的Hour,
在24小时制下(BIT7为0),BIT5-BIT4存储十位,BIT3-BIT0存储个位,所以需要把BIT5-BIT4取出来乘以10再加第四位.
在12小时制下(BIT7为1),BIT5指示AM/PM,BIT4存储十位,BIT3-BIT0存储个位,所以需要把BIT4取出来乘以10再加第四位. 如果值不为12的话,BIT5为1则加上12;如果为12的话则BIT5为0加上12(特判12PM与12AM).
月份、日期与年份比较简单,不复述了.
1 | unsigned char ReadHour(){ |
写入
DS1302写入极其简单,这里不讨论这个.
因为DS1302要开始走时的必要条件是其CH为0,所以需要初始化. 且需要把写保护关掉.
1 | void DS1302Init(){ |
超声波测距
超声波测距的原理是发送一段超声波,看他什么时候回来. 原理很简单,关键是如何计时与细节处理.
发送信号一般由8 个周期的 40kHz 方波信号构成. 用循环写的话大概就是每个电平保持12us.
为什么是 8 个? 这是一个权衡:如果脉冲太少,接收端的压电晶体可能还没充分起振;如果脉冲太多,发射周期太长会增加盲区距离(即还没发完,回声就回来了)。
此外,主包额外做了一点探究.为什么是 40kHz?40kHz 正好处于一个“黄金分割点”:既能保证几米到十几米的探测距离,又不至于因为衰减太快而检测不到回波。
1 | void Delay12us() { // 针对 12MHz 晶振的简单延时 |
如何计时?一般使用一个定时器负责计时. 以Timer1为例.
因为超声波测距较长,我们需要12T模式. 此时,定时器里的计数器+1意味着过去了1us. 所以计算公式就是:
此外,RX在发送完超声波之后会有如下状态: 低电平(初始状态), 跳变为高电平(发射超声波的一瞬间), 保持高电平(超声波在空中飞行的过程), 跳回低电平(接收到回声).
所以需要依次 while(RX == 0);与while(TF1==0 && RX==1)(防止太远了计数器溢出).
1 | unsigned CalculateDistance() { |
DS18B20 温度传感器
这部分需要先让DS18B20开始转换温度,再读取温度. 因为这两部需要有750ms以上间隔,所以我们把他拆分成两个函数.
OneWire函数原型:
1 | void Write_DS18B20(unsigned char dat); |
根据手册INITIALIZATION-ROM COMMANDS-FUNCTION COMMANDS的顺序:
因为onewire上只有DS18B20,所以不需要给出ROM地址,即ROM COMMANDS为SKIP ROM(0xCC).
1 | void StartConvertTemperature() { |
在至少750ms后给出FUNCTION COMMANDS的READ SCRATCHPAD(0xBE)来读取温度:
注意先给出低八位. 温度最小单位为1>>4,所以需要乘上.
The data transfer starts with the least significant bit of byte 0 and continues through the scratchpad until the 9th byte…
笔者2026.6.6考后按:
注意温度存储的数据有16位两个字节,需要用两个unsigned char或一个unsigned int存. 千万不要只用一个unsigned char导致数据截断. 不然你会获得10摄氏度的空调房.
1 | unsigned int ReadTemperature() { |
PCF8591 ADC/DAC
IIC函数原型:
1 | void I2CStart(void); |
ADC
根据时序图:
我们先addressing给出control byte,再addressing读取ADC值.
注意需要读取两次:PCF8591 内部有一个追踪保持电路。当你发送完控制字节后,它触发的是上一次转换结果的采样.
注意一般1才是NACK,释放总线.
1 | unsigned int GetVoltage() { |
但是这样浮点运算较慢,可以考虑用移位代替:
这种写法利用了 的级数展开。它依然全是移位和加法,但精度会无限接近于真正的除以 255.
最后可以写成:return (unsigned int)((temp + (temp >> 8) + 1) >> 8)*500;
ps:数学推导:从等比级数开始
我们要计算的是 。
为了利用位移(也就是 2 的幂次),我们把 写成 :
然后提取公因子 :
这时候,我们可以套用等比级数的展开公式 (当 时)。
令 ,展开得到:
2. 变换为位移运算
在计算机二进制里,除以 就是右移 8 位(
>> 8),除以 就是右移 16 位(>> 16)。所以,对于任何一个数 ,计算 就可以展开为:
这就是为什么我之前说 。因为在 16 位运算中,后面更小的项(如 )对结果的影响已经微乎其微了。
3. 那个“+1”是从哪来的?
你可能注意到公式里有一个
+1:((val + (val >> 8) + 1) >> 8)。这其实不是级数展开自带的,而是工程上的四舍五入补偿。
- 纯级数展开:得到的是一个无限接近但永远小于真实值的数(类似于 )。
- 计算机整除:计算机的
>>运算是向下取整(Floor)。- 加上 0.5 原理:在整数运算中,如果我们想实现四舍五入,通常会加上除数的一半。在除以 256 的环境下,加上一个偏移量(根据精度不同,有时是
128,有时简化为1)是为了让结果在截断时更贴近最接近的整数。
DAC
根据时序图,我们先addressing给出control byte,再直接给出对应digit值:
1 | void DAConvert(unsigned char dat) { |
NE555
与超声波测距类似,这里也需要使用定时器,不同的是这里作计数器用.
蓝桥杯板子的NE555输出接在P3.4上,所以只能用Timer0计数. 而我们只需要数1s内有多少个脉冲,就能算出它的频率了. 如果与主时钟冲突的话,记得把主时钟换成其他定时器.
STC15F2K60S2系列只有Timer0/1/2,别用Timer3和4!!
和需要转换与读取分开的温度一样,这里需要两个函数. 第一个开始测量,第二个读取. 注意需要把杜邦线把SIG引脚与P34连起来.
其次需要把定时模式改成计数器模式,修改 TMOD 寄存器中的 位为1.
1 | void StartDetectNE555() { |
样例
下面是用这个模板写的第15届蓝桥杯国赛满分代码.
Claude code+Deepseek V4是真好用啊,直接改到满分了