单片机考点精讲提高汇总

蓝桥杯提高笔记

From: 西风、左岚


掩码

使用掩码操作字节,通常有设置位请空位翻转位检查位四种方式。

  • 设置位,我们需要用到|(按位或)运算符。

    mask = 0b11001010
    data |= mask
    

    |运算:
    1 | Any = 1
    0 | Any = Any

    因此,对于mask中为1的位,将data对应位置1
    对于mask中为0的位,将data对应位保持。

  • 清空位,我们需要用到&(按位与)运算符。

    mask = 0b11001010
    data &= mask
    

    &运算:
    1 & Any = Any
    0 & Any = 0

    因此,对于mask中为1的位,将data对应位保持;
    对于mask中为0的位,将data对应位清0

  • 翻转位,我们需要用到^(按位异或)运算符。

    mask = 0b11001010
    data ^= mask
    

    ^运算:
    1 ^ Any = !Any
    0 ^ Any = Any

    因此,对于mask中为1的位,将data对应位清0
    对于mask中为0的位,将data对应位保持。

  • 检查位,我们需要用到&运算符。

    mask = 0b11001010
    result = (data & mask)
    

    也就是,对于mask中为0的位,将data对应位清0,它们不会被检查。被检查的是对应mask中为1的位。一般检查一位。需要注意的是,这里的检查位,实际是检查某位是否为1

以下是常用的操作字节中一位数据的思路:

// 伪代码
// 掩码 = 1<<欲操作位数
MASK = 1<<addr;
// 检查一位:判断 &掩码 是否等于 掩码
if((data & MASK) == MASK);
// 设置一位:|= 掩码
data |= MASK;
// 重置一位:&= ~掩码
data &= ~MASK;
// 翻转一位:^ 掩码
data ^= MASK;

要注意操作符优先级,可以无脑加括号,反正最后都会被编译器优化。


变量类型和取值范围

类型 stdint 大小 取值范围
bit - 1 位 0 ~ 1
unsigned char uint8_t 1 字节 0 ~ 255
char int8_t 1 字节 -128 ~ 127
unsigned int uint16_t 2 字节 0 ~ 65535
int int16_t 2 字节 -32768 ~ 32767
unsigned long (int) uint32_t 4 字节 -2147483648 ~ 2147483647
long (int) int32_t 4 字节 0 ~ 4294967295
float - 4 字节 ±1.175494351×10⁻³⁸ ~ ±3.402823466×10³⁸
double - 4 字节 ±1.175494351×10⁻³⁸ ~ ±3.402823466×10³⁸

内存管理

内存类型

关键字 大小 访问速度 说明
data 128 B 0.083 μs 内部低RAM。直接寻址,默认存储区
idata 256 B 0.083 μs 整块内部RAM。间接寻址,哪里有空间占哪里
xdata 64 KB 0.17 μs 外部RAM,访问较慢
pdata 256B 0.17 μs 外部RAM当前页的子集,默认为最低页
code 61 KB 0.17 μs

Keil编译信息里,Program Size里,data=指内部RAM占用,xdata=指外部RAM占用,code=指Flash占用。

[!IMPORTANT]
通过xdatapdata关键字声明的全局变量,初始值不为0,需要手动初始化!因为外部RAM不会上电清除。

典型错误

idata溢出无明显报错,但会导致程序功能异常,如数码管、LED显示异常。
一般在data区使用超过210字节时可能会出现。


编译优化

优化级别

优化级别 核心优化内容 简要说明
0级 常数折叠 预先计算常量表达式的结果,用常数代替;优化内部数据和位地址的访问;简化跳转指令 。
1级 死码消除 删除无用的代码段;通过分析简化或删除多余的条件跳转 。
2级 数据覆盖 标记出适合静态覆盖的数据,由连接器分析并优化数据存储空间,实现数据内存的复用 。
3级 窥孔优化 清除冗余的MOV指令,用更高效的操作代替复杂操作 。
4级 寄存器变量 尽可能将自动变量和函数参数分配到工作寄存器,加快访问速度;优化对外部存储器的直接访问;消除局部重复的子表达式;优化switch/case语句 。
5级 全局公共子式消除 在一个函数内,相同的子表达式只计算一次,结果被保存复用;优化用常量填充存储区的简单循环 。
6级 回路循环 对程序循环进行优化,以获得更快的执行速度 。
7级 扩展入口优化 优化对指针和数组的访问,在合适时使用DPTR数据指针,以减小代码和提高速度 。
8级 公共尾部合并 当一个函数被多次调用时,复用其中一些共同的设置代码,减少代码长度 。
9级 公共子程序块 检测重复的指令序列,将它们转换为子程序。编译器甚至可能重排代码以发现更多重复序列,从而最大化地缩减代码大小 。

Keil C51默认为8级优化。在需要用到软件延时的项目里,如果发现效果不如预期,应参考STC-ISP工具给出的代码优化级别建议

volatile 关键字

volatile 是一个非常重要的类型修饰符,它告诉编译器:“这个变量的值可能会在程序本身无法控制的情况下被改变,请不要对它的访问做任何优化,每次使用都必须从内存中直接读取或写入。”

核心作用

  1. 防止编译器优化
    编译器为了提高效率,可能会将变量的值缓存在寄存器中,后续的读写都基于寄存器副本,而不是实际的内存地址。volatile 强制编译器每次访问都从内存地址读写,确保操作的“真实性”,防止编译器优化导致无法得到最新的值
  2. 保证可见性
    变量的修改能立即反映到内存中,且后续读取一定能获取到最新的值(对编译器而言,不考虑硬件缓存一致性,仅针对编译器生成的代码)。

常见使用场景

  1. 在中断中修改访问的变量
  2. 多任务(线程)共享的变量
  3. 硬件寄存器变量
  4. Debug调试

调试

步骤:
image-20260315221717811

  1. 连接单片机,在Keil仿真设置页里点击将所选目标单片机设置为仿真芯片,烧录STC仿真驱动代码
  2. 按照信息框指引,将开发板物理断电(拔掉USBC线),随后重新上电
  3. 打开需要调试的项目,项目须包含STARTUP.A51文件

image-202603152229090916

  1. 点击魔法棒,进入Debug页,选中右侧Use:,选择STC Moniter-51 Driver,并在Settings里配置好端口和波特率。
  2. 编译项目,随后即可进入调试界面

链接库的忧虑

Keil C51里调用fabs()时,即使#include <math.h>,最终编译进程序里的函数并不是整个 math 库,而是只链接你实际用到的函数

简单说结论:

  • #include <math.h> 只是声明函数原型
  • 不会把整个 math 库复制进程序
  • 编译链接阶段只会把你调用的函数(如fabs)链接进去

详细过程

  1. 预处理阶段

    #include <math.h>
    

    只是把 math.h 里的 函数声明插入,例如:

    double fabs(double x);
    double sin(double x);
    double cos(double x);
    ...
    

    这里只是声明,没有函数实现。

  2. 编译阶段
    你的代码:

    double a = fabs(x);
    

    编译器会记录:需要一个 fabs 的实现

  3. 链接阶段
    Keil的Linker会在库文件里找 fabs 的实现。
    math库内部是模块化的,类似:

    math.lib
     ├─ fabs.obj
     ├─ sin.obj
     ├─ cos.obj
     ├─ sqrt.obj
     ...
    

    链接器只会把fabs.obj加入最终程序。
    如果你没调用sin()cos()sqrt()……,这些都不会被链接进去。


滤波

选择排序

选择排序是一个比冒泡排序占用硬件资源更少,且更易理解的排序算法。它的核心思想就是,在每一轮排序中,找到未排序元素中的最小元素,并把它放在已排序元素的最后。如此遍历n-1次,即可将数组由小到大排列。

void selection_sort(unsigned char *arr, unsigned char n)
{
    unsigned char i, j;
    unsigned char min, temp;
    
    for(i=0; i<n-1; i++)
    {
        min = i;
        for(j=i+1; j<n; j++) // 注意这里一定要是n,意思是遍历到最后否则最后一个元素永远不会被比较
        {
            if(arr[j] < arr[min])
                min = j;
        }
        
        temp = arr[i];
        arr[i] = arr[min];
        arr[min] = temp;
    }
}

中值滤波

中值滤波是拿到一组数据,从小到大排序,取最中间的数作为结果。
中值滤波适合过滤突发尖峰、脉冲噪点、坏点、异常值,例如DS18B20初上电的85摄氏度的温度跳变。

#define FILTER_WINDOW 5	// 过滤窗口大小(推荐用奇数)
// 以过滤DS18B20的浮点数据为例
float filter_arr[N] = {0}; // 被过滤的窗口数组
unsigned char filter_index = 0;// 窗口当前的索引
unsigned char filter_count = 0;// 窗口当前的数据量

float filter_median(float dat)
{
    float sorted_dat[N]; // 排序窗口数据用的数组
    
    filter_arr[filter_index] = dat;	// 添加数据
    filter_index = ++filter_index % N;// 索引更新(12345->12345->12345->...)
    // 增加窗口数据数量,直至窗口满
    if(filter_count < N)
        filter_count++;
    // 赋值窗口到排序数组
    memset(sorted_dat, filter_arr, N);
    // 选择排序
    selection_sort(sorted_dat);
    // 返回中值,N/2, 因此推荐窗口大小用奇数
    return sorted_dat[N/2];
}

均值滤波

均值滤波适合平稳、随机噪声、变化慢的数据流,不适用于有突发尖刺的数据流,因此也被称为“滑动滤波”,是一种线性滤波。适合过滤例如超声波距离波动的数据流。

#define FILTER_WINDOW 5	// 过滤窗口大小(推荐用奇数)

unsigned char filter_arr[N] = {0}; // 被过滤的窗口数组
unsigned char filter_index = 0;// 窗口当前的索引
unsigned char filter_count = 0;// 窗口当前的数据量
float filter_sum = 0.0f;	// 窗口数据总和

float filter_mean(unsigned char dat)
{
    filter_sum -= filter_arr[filter_index];
    filter_arr[filter_index] = dat;
    filter_sum += dat;
    filter_index = ++filter_index % N;
    
    if(filter_count < N)
        filter_count++;
    
    return filter_sum / filter_count;
}

调度器

在最新的大模板中,我们舍弃了主循环轮询,而改用软件调度器轮询。采用调度器的一大好处是,减少了主循环轮询方式里,大量不必要的进入函数行为。

/* main.c */
// 调度器
typedef struct
{
    void (*func)(void);			// 任务回调函数
    unsigned long period_ms;	// 任务周期(毫秒)
    unsigned long last_run_ms;  // 上次运行时间
} task_t;

idata task_t sched_tasks[] =
{
    {key_proc, 20, 0},
	{us_proc, 100, 0},
	{led_proc, 1, 0},
	{temp_proc, 200, 0},
	{seg_proc, 50, 0},
	{rtc_proc, 100, 0},
	{adda_proc, 100, 0},
	{uart_proc, 10, 0}
};

idata unsigned char sched_tasks_num = sizeof(sched_tasks) / sizeof(task_t);

void sched_run()
{
    unsigned char i; // 遍历变量
    for(i=0; i<sched_tasks_num; i++)
    {
        unsigned long current_tick = tick; // 当前时间(在哪声明都可,但要保证在循环内部赋值)
        task_t *task = &sched_tasks[i]; // 临时变量
        // 一定是指针!不然只是浅修改!!!
        if(current_tick >= task->period_ms + task->last_run_ms)
        {
            task->last_run_ms = current_tick;
            task->func();
        }
    }
}

// 最后在主循环中调用 sched_run 即可

指令解析


串口

// 假如使用串口1、定时器1,用定时器0计数
bit uart_rx_busy;				// 接收忙标志位
unsigned char uart_rx_tick;		// 空闲计时
unsigned char uart_rx_buf[16];	// 接收缓冲区
unsigned char uart_rx_buf_index;// 接收缓冲区索引

void handle(char *dat);

/* ================== main.c ==================*/
// 初始化串口
void uart1_init(void)	//9600bps@12.000MHz
{
	SCON = 0x50;		//8位数据,可变波特率
	AUXR |= 0x40;		//定时器时钟1T模式
	AUXR &= 0xFE;		//串口1选择定时器1为波特率发生器
	TMOD &= 0x0F;		//设置定时器模式
	TL1 = 0xC7;			//设置定时初始值
	TH1 = 0xFE;			//设置定时初始值
	ET1 = 0;			//禁止定时器中断
	TR1 = 1;			//定时器1开始计时
	ES = 1;				//使能串口1中断
    EA = 1;				// 开总中断
}

// 串口中断服务
void uart1_isr(void) interrupt 4
{
    /*	这一段的TI标志位处理已经在putchar中阻塞监听清除了
	if (TI)				//检测串口1发送中断
	{
		TI = 0;			//清除串口1发送中断请求位
	}
	*/
	if (RI)				//检测串口1接收中断
	{
        // 保存数据到缓冲区
        uart_rx_busy = 1;	// 置接收忙标志位
        uart_rx_tick = 0;	// 重置空闲计时
        uart_rx_buf[uart_rx_buf_index++] = SBUF;// 将数据保存到缓冲区
		RI = 0;			//清除串口1接收中断请求位
        
        // 处理溢出
        if(uart_rx_buf_index > 16)
        {
            uart_rx_buf_index = 0;
            memset(uart_rx_buf, 0, 16);
            // 这里的处理办法是溢出直接清空缓冲区,具体情况要看实际需求
        }
        
	}
}

void timer0_isr() interrupt 1
{
    // 毫秒定时器
    if(uart_rx_busy)
        uart_rx_tick++;	// 串口接收计时增加
}

void uart_proc()
{
    if(uart_rx_index == 0)	// 缓冲区无数据,直接退出,防止多余判断占用硬件资源
        return;
   	if(uart_rx_tick >= 16)	// 超过设定的超时时间,进入IDLE,超时断帧
    {
        uart_buf[uart_rx_buf_index] = '\0';// 封底
        uart_rx_busy = 0;	// 清空接收忙标志位
        uart_rx_tick = 0;	// 清零接收计时
        uart_rx_index = 0;	// 清零索引
        handle(uart_buf);	// 处理函数
        memset(uart_buf, 0, 16);// 清空缓冲区
    }
}

/* ================== uart.c ==================*/
// 重定向putchar
char putchar(char ch)
{
    SBUF = ch;		// 填入缓冲区
    while(TI == 0);	// 等待发送完成(等待TI置1)
    TI = 0;			// 手动清空标志位(清零TI标志位)
    return ch;		// 手动返回
}

PWM

PWM(脉冲宽度调制)是一种通过调整数字输出信号的高低电平时间比例来控制功率传递的技术,其核心概念是占空比。我们需要根据题目要求来实现,如:占空比为20%的100Hz的PWM信号,意味着1个PWM周期是10ms,且高电平持续2ms,低电平持续8ms。

[!WARNING]
**PWM所用的定时器,优先级不应比调度器/轮询所用定时器高。**例如调度器/轮询使用定时器1,PWM就必须使用定时器2,而非定时器0。因为一般情况下,PWM信号频率远高于轮询频率,如果PWM所用定时器优先级更高,则硬件很难进入调度器/轮询中断。

  • LED调光

    // PWM周期:10 ms
    #define PWM_PERIOD 10
    
    unsigned char pwm_compare = 3; // PWM占空比(3ms : 7ms)
    unsigned char pwm_count = 10; // PWM周期计数(1ms/count)
    
    void timer0_init();
    
    void timer0_isr() interrupt 0
    {
        // ...
        
        if(++pwm_count >= PWM_PERIOD) // PWM计数自增
            pwm_count = 0;
        if(pwm_count < pwm_compare)
            // 点亮某个灯
        else
            // 熄灭某个灯
            
        // ...
    }
    
  • 数码管调光
    数码管本身采用8ms为周期,每1ms扫描并点亮一位的方式动态显示。如果基于此去PWM调光,可能会导致数码管闪烁,因此数码管调光几乎不考。
    更精细、稳定的调光,或许可以考虑在1ms扫描点亮一位的时间内,进行PWM调光。

  • PWM控制电机输出频率不变、占空比可变的波
    我们需要获得一个1KHz频率的指定占空比的波。

    #define PWM_PERIOD 10 // 1/1KHz = 1ms = 10*100us
    unsigned char pwm_count = 0;
    unsigned char pwm_compare = 3; // 300us : 700us
    
    void timer1_init(); // 100微秒@12.000MHz
    // 注意要用定时器1
    
    void timer1_isr() interrupt 3
    {
        // ...
        
        if(++pwm_count >= PWM_PERIOD)
        	pwm_count = 0
        if(pwm_count < pwm_compare)
            motor(1);
        else
            motor(0);
        
        // ...
    }
    
  • PWM控制电机输出指定频率可变、占空比可变的波

    unsigned char pwm_compare;
    unsigned int pwm_frequency;
    
    // 确定一个最小单位:10us
    void timer1_init(); // 10微秒@12.000MHz
    
    void pwm_period_update()
    {
        pwm_frequency = get_pwm_frequency(); // 获取PWM信号频率
        unsigned long pwm_period_us = 1000000UL / pwm_frequency;
        unsigned char pwm_period = pwm_period_us / 10; // 单位:10us
        pwm_compare = get_pwm_compare(); // 获取占空比
        
        // 平滑过渡
        TR1 = 0;
        motor(0);
        pwm_count = 0;
        TR1 = 1;
    }
    
    void timer1_isr() interrupt 3
    {
        // ...
        
        if(++pwm_count >= pwm_period)
            pwm_count = 0;
        if(pwm_count < pwm_compare)
            motor(1);
        else
            motor(0);
        
        // ...
    }
    

板载外设巩固

数码管

位选

位选通过移位操作实现,CT107D开发板上共有8位数码管,对应一个字节的数据。其中写1为选中,写0为不选中。
一次选中多位一般是没有意义的。由于段码线是共用的,所有被选通的位都会显示相同的字符。并且,驱动电路的总电流会被分摊到多个数码管上,每个数码管的亮度会明显下降,可能变得暗淡或不均匀。

段选

CT107D开发板的数码管模块为共阳极,即段位低电平点亮。可在STC-ISP的工具 字库生成工具生成段码。

小数点

  1. 参数判断
    数码管驱动显示函数原型:
    void seg_disp(unsigned char wela, unsigned char dula, bit dp);
    

在送段码的时候,只需判断dp,然后选择是否将最高位清0即可。
数字8的段码为0x80,因此清0最高位只需& ~0x80

  1. ASCII码判断
    省去主函数内的unsigned char seg_dp变量的空间,而采用**段码数组索引 + ‘,’(半角逗号)**的方法。
    ,的ASCII码是44,远大于绝大多数情况下段码数组的长度。只需在中断函数判断传入的dula是否大于等于44,然后给dp参数合适的值。一般段码数组长度不会超过20,判断是否大于20也一样。也可以省去dp参数,在驱动函数里修改。

余晖

成也余晖,败也余晖。
当光刺激进入人眼后,视神经对刺激的感应不会瞬间消失,而是会保留一段极短的时间,这就是余晖效应
利用这个效应,我们让多位数码管快速轮流点亮,即动态扫描,只要每位点亮的时间间隔小于视觉暂留时间(通常刷新频率大于60Hz),人眼就会感觉所有数码管是同时持续点亮的,而不会看到闪烁。而如果频率太慢(>10ms),人眼就会看到闪烁,这就是“余晖”消失的感觉。

// 定时器中断函数内
seg_pos = ++seg_pos % 8;
seg_disp(seg_pos, seg_buf[seg_pos], seg_dp);

但是,由于数码管各个位的段码是共用的,在动态切换的瞬间,由于余晖效应,某个位上一次扫描的残影还在,而新的段码已经传入,就会导致该位上出现残影,破坏显示效果。因此就需要消影。只需要在上一次显示之后,先把段码输出全部清零,利用这极短的“黑屏”时间,让上一帧的余晖彻底消散。

// 数码管驱动函数(seg_disp)内
latch(0xFF, 7); // 7为段选

高位熄灭

[!WARNING]
要看清题目要求,不可自作聪明。
一般情况下,不必将数值拆成digits[],以下两种方法就能简单实现高位熄灭:

  1. 数值比较
    seg_buf[0] = (num >= 1000) ? num/1000%10 : 10;
    // 10 为熄灭
    

注意>= 1000的运算符,不要漏写等号,也可以> 999

  1. 前位检测
    seg_buf[0] = (num/1000%10 == 0) ?
        10 : num/1000%10;
    // 先确定最高位,之后都以他为根基准
    seg_buf[1] = ((num/100%10 == 0) && seg_buf[0] == 10) ?
        10 : num/100%10;
    seg_buf[2] = ((num/10%10 == 0) && seg_buf[1] == 10) ?
        10 : num/10%10;
    // ...
    

无论哪一种方法,都要保证在一轮扫描中,数码管前端函数里每一位seg_buf只被赋值一次,因为如果赋值多次,很有可能会被中断处理打断,从而引起bug。诸如“先赋值一遍,再循环遍历各位判断前导零后熄灭”的方法更是如此。

负数显示和高位熄灭

假设fixer的范围是-99 ~ 99,易懂的方法是:

char fixer;	// 注意变量类型
/* 数码管前端函数内 */
unsigned char opposite_fixer = -fixer; // 获取相反数 
if(fixer <= -10)
{
   seg_buf[5] = 11;	// 负号
   seg_buf[6] = opposite_fixer/10%10;
   seg_buf[7] = opposite_fixer%10;
}
else if(fixer < 0)	// 注意是小于,不要有-0
{
   seg_buf[5] = 10;
   seg_buf[6] = 11;
   seg_buf[7] = opposite_fixer%10;
}
else if(fixer < 10)
{
   seg_buf[5] = 10;
   seg_buf[6] = 10;
   seg_buf[7] = fixer%10;
}
else
{
   seg_buf[5] = 10;
   seg_buf[6] = fixer/10%10;
   seg_buf[7] = fixer%10;
}

采用阶梯式判断。

闪烁

需要两个核心变量:

bit seg_blink;					// 现实状态状态位
unsigned int seg_blink_tick;	// 闪烁计时

注意变量类型

从右到左显示

思路很简单,需要知道要显示数字的位数,然后以位数为次数遍历一遍即可。

unsigned char i;
unsigned char password_digits;	// 其他子程序中随时修改
unsigned char password_input[8];// 需要显示的数字
// password_input是从0往右存储的,
// 但是我们希望数码管从最右位依次抬高显示到最左位
// 从右到左显示
for(i=0; i<password_digits; i++)
{
    seg_buf[7-i] = password_input[i];
}
// 未输入的右边都熄灭
for(; i<8; i++)
{
    seg_buf[7-i] = 10;
}

在实际的应用场景中,例如这里的密码输入,还要判断password_digits是否大于0,如果等于0是否要全部熄灭,如果使用password_input_index输入索引,还要找索引值和实际遍历计数值的关系等等,具体情况具体分析。

DS1302 (RTC)

BCD转换

// 十进制数 转 BCD
unsigned char dec2bcd(unsigned char dec)
{
    return (dec/10*16 + dec%10);
}
// BCD 转 十进制
unsigned char bcd2dec(unsigned char bcd)
{
    return (bcd/16*10 + bcd%16);
}

注意,在两位数范围内,dec2bcd函数的效果等同于手动在十进制前面加0x的效果,但题目中难免涉及非手动传值的地方。可以根据具体情况决定是否写出这个函数。

时间参数写入

unsigned char rtc_time[3];	// RTC时间数组(时分秒)
// 写入时间
rtc_set_time(unsigned char *time)
{
    unsigned char i;	// 计数变量
    
    rtc_write(0x8E, 0x00);	// 关闭写保护
    rtc_write(0x80, 0x80);	// 时钟停止
    
    for(i=0; i<3; i++)
    {
        rtc_write(0x84 - 2*i, dec2bcd(time[i]));
    }
    
    rtc_write(0x8E, 0x80);	// 开启写保护
}
// 写入日期(后续根据需求自己写)
rtc_set_date(unsigned char *date);
// ...

注意,这里的rtc_set_time只考虑24小时制的情况,即12/24位总是为0,且AM/PM位总是被覆盖。如果考题设计12小时制相关考点,需要根据实际情况改良驱动。
任何写操作必须配置WP位。在关闭写保护之后,一定要停止时钟,即置CH位为1。因为如果不停止时钟,一定会导致写入后的第一个分钟不完整。且一定要在发送所有数据之后再打开时钟,即秒数据一定要最后传入,这样,传入秒数据的同时,CH位也自然而然的被清零,始终重新开始。换句话说,为了保障时钟精度,初始化时需要先停止时钟,待配置完成后再手动打开时钟。以保证关写保护写寄存器开写保护的原子操作。

时间参数读取

// 读出时间
void rtc_read_time(unsigned char *time)
{
    unsigned char i;	// 计数变量
    
    EA = 0;				// 关闭总中断
    
    for(i=0; i<3; i++)
    {
        time[i] = bcd2dec(rtc_read(0x85 - 2*i));
    }
    
    EA = 1;				// 重新开启总中断
}
// 读出日期(后续根据需求自己写)
rtc_read_date(unsigned char *date);
// ...

在读取周期之前,需要关闭总中断,否则会导致读取出错。但是也别忘记在读取之后重新开启总中断!

闹钟

定义另一个数组,每次读取时间与之判断即可。

获取触发的时间

@rtc_read_time_decorator
def func():
    pass

NE555

计数器

测量NE555的频率,需要配置一个计数器,在单位时间内检测脉冲个数并计算。在CT107D开发板上,NE555模块的脉冲输出被引出在SIG脚,将SIG脚的脉冲送至计数器的外部脉冲输入脚即可实现计数功能。
计数器0的外部脉冲输入脚T0复用在了P34,计数器1的外部脉冲输入脚T1复用在了P35。而在开发板上,只有P34引脚被引出。因此,只能选用并配置计数器0来接收NE555的脉冲

配置计数器:

[!TIP]
与少年y的思路不同,左岚的思路是:配置计数器0为16位不可重装计数器,不使能中断,将计数值初始化为0x00每秒取一次数据,拼合TH0、TL0即可得到上一秒收到脉冲的个数,随后手动装填计数值为0x00
而少年y的思路是,配置计数器0位16位自动重装计数器,使能中断,将计数值初始化为0xFF,此后每一次接收脉冲都会引发中断,使变量count自增,并自动装填计数值为0xFF,每秒取一次count计算频率并清零之。
二者作比,少年y的方案会占用大量CPU资源,不推荐;左岚的方案使得计数器0完全不占用CPU资源,一个独立的电子木鱼,非常地夯。

// 初始化计数器0
void timer0_init(void)
{
	TMOD &= 0xF0;		//设置定时器0模式
    TMOD |= 0x05;		//(计数器,16位不自动重装)
	TL0 = 0x00;			//设置定时初始值
	TH0 = 0x00;			//设置定时初始值
	TF0 = 0;			//清除TF0标志
	TR0 = 1;			//定时器0开始计时
    // 不使能定时器0中断
}

unsigned int ne555_freq;
unsigned int time_1s_tick;

// 初始化定时器1
void timer1_init(void)		//1毫秒@12.000MHz
{
	AUXR |= 0x40;			//定时器时钟1T模式
	TMOD &= 0x0F;			//设置定时器模式
	TL1 = 0x20;				//设置定时初始值
	TH1 = 0xD1;				//设置定时初始值
	TF1 = 0;				//清除TF1标志
	TR1 = 1;				//定时器1开始计时
	ET1 = 1;				//使能定时器1中断
    EA = 1;					// 允许总中断
}
// 中断服务函数
void timer1_isr(void) interrupt 3
{
    if(++time_1s_tick == 1000)
    {
        time_1s_tick = 0;
        ne555_freq = (TH<<8) | TL;
        TH = TL = 0;
    }
}

[!NOTE]
charshortint小的整数类型进行算术运算(包括移位)时,编译器会先将其隐式转换int类型(或者unsigned int,取决于值的范围),然后再执行运算。这就是整数提升规则。
如果操作数已经是int,就不会再有进一步的提升,直接以 int 类型进行运算,此时再进行移位操作,对于有符号整数,可能会产生UB(未定义行为)。
最后赋值时,编译器会根据你给出的类型保留或截断。

初始化顺序

应遵循先初始化计数器(Timer0)、再初始化秒表(Timer1)的顺序,否则会导致第一次测量精度不够。

第一次测量结果误差

在第一个一秒内,秒表的第一个中断尚未触发,未进行频率计算,但是数码管已经在把ne555_freq写入seg_buf从而显示了,可想而知为0。这种行为,会被4T判错,因为0并不是预期的初始值。
我们可以定义一个标志位,初始化为0。在第一次频率计算完成后,置标志位,数码管在看到标志位进行显示。

bit ne555_freq_init;

一定要在中断的动态扫描里去判断,否则无效。

// 数码管动态更新
if(ne555_freq_init==1)
{
    seg_pos = ++seg_pos % 8;
    seg_disp(seg_pos, seg_buf[seg_pos], 0);
}

接线

瞪大你的狗眼,别再把线接错了。别把SIG接到GND上,要不然直接就废了。你忘了VCC短接GND的后果了吗?零点几秒都不到,你想死吗?

按键问题

测量NE555频率需要用到计数器0的外部输入引脚T0 (P34),而P34又接矩阵键盘第4列,所以为了确保频率精确,必须注释按键驱动里有关第4列的代码。当然,这属于硬件冲突,是开发板的设计问题,考题中一定不会出现同时测量频率并且使用第4列键盘的情况。

按键

冲突

  • 由于CT107D开发板上,串口的TXD、RXD分别被复用在了P30和P31,也就是矩阵键盘的ROW1和ROW2。在串口和矩阵键盘同时使用的情况下,我们需要:

    1. 强制使用列扫描、行检查
    2. 每轮扫描之前关闭串口定时器,扫描完成后重新打开
      如果使用行扫描,就会使P30和P31频繁被拉低,扰乱串口;如果扫描前关闭串口定时器,有串口数据到达时,或者发送串口数据时,会被key_read捕获到,并被误认为是按键按下。
      关闭串口定时器操作TR1或T2R即可,由于AUXR不可位寻址,因此需要调整AUXR来操作T2R。
      更为安全的方法,就是不动串口,去动按键。
    /* 注意硬件冲突
     * 要用到串口,就在每轮扫描之前,
     * 全部释放列线,然后去看RXD(P3.0)是否为低电平
     * 没拉低的情况下,即便按下P3.0,也不会为低电平
     * 此时为低电平,只能说明RXD正忙,于是直接拒绝读入按键
     * 不检查TXD(P3.1)的原因是,串口发送是由我们决定的
     * CPU在发送串口数据的时候不会同时扫描
     * 而接收数据是随时都有可能的
    **/
    
    sbit ROW1 = P3^0; // RXD
    sbit ROW2 = P3^1; // TXD
    sbit ROW3 = P3^2;
    sbit ROW4 = P3^3;
    
    sbit COL1 = P4^4;
    sbit COL2 = P4^2;
    sbit COL3 = P4^5;
    sbit COL4 = P4^4;
    
    unsigned char key_read()
    {
    	unsigned char key = 0;
    	
    	COL1 = COL2 = COL3 = COL4 = 1;
    	if(!ROW1) return 0;
    	if(!ROW2) return 0;
    
    	COL1 = 0; COL2 = COL3 = COL4 = 1;
    	if(!ROW4) key = 4;
    	if(!ROW3) key = 5;
    	if(!ROW2) key = 6;
    	if(!ROW1) key = 7;
        
        // ...
        
        return key;
    }
    
  • 对于按键和NE555的冲突,是纯粹的硬件冲突,无法避免,上文已有提及,此处不做赘述。

释放

在进行矩阵键盘的行/列扫描之前,结合具体情况,需要先将各行/列引脚释放(如果要用到串口,这个操作应在关闭串口定时器之后)。

长短按

按键的长短按考点,有以下3种情况:

  1. 长按1s,不用松手,执行一次LED翻转;短按,数据+1

    // S4
    void led_toggle();	// LED翻转函数
    void data_add(unsigned char n);// 数据加1函数
    bit	 key_lfunc_runned;// 是否已经执行
    bit	 key_pressing;	// 按键按下标志位
    unsigned char key_pressing_tick;// 按键按下时间
    
    /* key_proc() */
    // 按下S4
    if(key_down == 4)
    {
        key_pressing = 1;		// 置按下标志位
        key_pressing_tick = 0;	// 按住计时初始化
        key_lfunc_runned = 0;	// 长按函数未调用
    }
    // 长按判断
    if(key_pressing_tick >= KEY_LONG_PRESS)
    {
        if(!key_lfunc_called)
            led_toggle();
    }
    // 松开S4
    if(key_up == 4)
    {
        if(key_pressing_tick < KEY_LONG_PRESS)
    		data_add(1);
        
        /* 这里注意顺序!下方的代码不要放到 if 判断前面
         * 如果先置 tick 为 0,假如原本按下了 1200ms
         * 应为长按,不触发短按
         * 但继续往下,如果 if 判断在后面,就会导致多触发一次短按!
        */
        
    	key_pressing = 0;		// 清空按下标志位
        key_pressing_tick = 0;	// 按住计时
    }
    
  2. 长按1s, 不用松手,数据快速增加;短按,数据+1

    // S5
    void data_add(unsigned char n);
    bit	 key_pressing;
    bit  key_pressing_tick;
    
    // 按下S5
    if(key_down == 5)
    {
        key_pressing = 1;
        key_pressing_tick = 0;
    }
    // 长按判断
    if(key_pressing_tick >= KEY_LONG_PRESS)
    {
        if(key_old == 5)
            data_add(1);
    }
    // 松开S5
    if(key_up == 5)
    {
        // 短按判断
        if(key_pressing_tick < KEY_LONG_PRESS)
            data_add();
        // 注意顺序
        key_pressing = 0;
        key_pressing_tick = 0;
    }
    
  3. 长按1s,数据+5;短按,数据+1

    // S6
    void data_add(unsigned char n);
    bit	 key_pressing;
    bit  key_pressing_tick;
    
    // 按下S6
    if(key_down == 6)
    {
        key_pressing = 1;
        key_pressing_tick = 0;
    }
    // 松开S6
    if(key_up == 6)
    {
        // 短按判断
        if(key_pressing_tick < KEY_LONG_PRESS)
            data_add(1);
        else
            data_add(5);
        // 注意顺序
        key_pressing = 0;
        key_pressing_tick = 0;
    }
    

密码门

没什么可说的。
唯一需要注意的就是有关数字键的问题,可以直接去叠case,也可以修改数字键对应键码为100 + num,本质是相同的。

双按键

如果只考特定组合的双按键,只需修改底层,独立出来一个键码值即可。

P44 = 1; P42 = 0; P35 = 1; P34 = 1;
if(!P33) key = 8;
if(!P32) key = 9;
if(!P31) key = 10;
if(!P30) key = 11;
if(!P33 && !P32) key = 89;

双按键的判断逻辑与普通单按键不同,且会影响组合内的单键单独判断。

// S8, S9
if((key_val==89) && (key_old!=89))
{
    // 双按键按下
}
if(key_up == 8)
{
    // S8 被影响,只能抬起响应
}
if(key_up == 9)
{
    // S9 被影响,只能抬起响应
}

多击

判断多击,即在一个周期内统计按键按下的次数,随后就进入对应的事件。
如果考到,官方应该会给出一个阈值,这里假设是100ms。

// S4
bit	 key_pressed;	// 按下标志位
unsigned char key_pressed_tick;// 计时
unsigned char key_pressed_count;// 按下次数
// 按下S4
if(key_down == 4)
{
    key_pressed = 1;		// 置按下标志位
    key_pressed_tick = 0;	// 重置计时值
    key_pressed_count++;	// 次数加一
}
// 超过阈值
if(key_pressed_tick >= KEY_MULTICLICK_THRESHOLD)
{
    switch(key_pressed_count)
    {
        case 1:
            // 单击
            break;
        case 2:
            // 双击
            break;
        // ...
    }
    key_pressed = 0;
    key_pressed_tick = 0;
    key_pressed_count = 0;
}

AT24C02 (EEPROM)

有关I²C协议

如果通信出现问题,可以尝试修改官方底层iic.c中的延时时间:

#define DELAY_TIME	10	// 可以修改成 5

并且,记得自己定义sclsda。不记得也没关系,会报错。

保存非单字节类型变量

对于Keil C51,剔除bit,无非就是1 Byte、2 Byte、4 Byte三种大小的单位,而EEPROM只支持一次写入1字节。但由于C指针指向单个字节,我们只需在len参数内传入sizeof(dat)即可,以一字节宽度截断发送存储。读出数据时需要手动拼合。
要存入浮点数,需要保留一定精度后转换成整数,以整数的形式存储。后取出的时候再除以对应的数,赋值给浮点数。

加锁机制

在自己做模拟题的时候,我们会多次操作EEPROM,却不会清理EEPROM。这就导致有时意外读出了远古时期的数据,而这个数据本应为0或者如何,这就是数据污染。为了防止数据污染,我们给每一个写入EEPROM的数据一个校验值(或者密钥)。在读数据时,先检测密钥是否匹配,再读出。只要能保证,一套题目,或者是一个项目所用的密钥不同即可。

[!IMPORTANT]
自己用板子模拟的时候需要加锁,但是正式比赛的时候不需要加锁

unsigned char eeprom_key = 5; // 密钥
unsigned char dat;	// 要存存储的数据
// 在0x00处存了dat
eeprom_write(&dat, 0x00, 1);	// 写入数据
eeprom_write(&eeprom, 0x08, 1);	// 写入密钥
unsigned char dat1;	// 待更新的数据
unsigned char temp;

eeprom_read(&temp, 0x80, 1);
if(temp == eeprom_lock)
    eeprom_read(&dat1, 0x00, 1);

好玩吧。

4T测评Judge的时候又不用我的板子,跟我有什么关系?没有关系,是4T自己的原因,它自己的EEPROM被污染了。

时序要求

I²C通信对时序要求严格。进行写操作时,由主机主导,包含足够的延时保护,中断不会影响最终数据的发送。但进行读操作时,从机一股脑发送len个字节到总线,一旦被中断打断(例如通过按键操作EEPROM的情况下),主机就会读取不及时,造成丢包,读取失败,但我们也不推荐手动关开中断,除非有在程序中读取的情况,因此有关上电读取的操作,必须放在定时器初始化之前

PCF8591 (AD/DA)

两路读取

因为每轮AD读取会返回上一轮的结果,所以涉及到多路读取时,如果按顺序读取每路,就会造成数据颠倒。解决方法,要么连续读两次(空读、真读),要么交换地址。交换地址是推荐的,因为这个占用更少的硬件资源。

读写通吃

假如项目或题目同时有读、写电压的操作,如果在读电压时控制字节中清零电压输出位,则会中断电压输出。因此在这种情况下,读电压的控制字节应为0x40 + channel

超声波

软件延时配置

推荐选择STC89Y1指令集,编译优化等级设置为默认8级即可。

PCA 配置

为避免占用过多定时器,可以用PCA来记录间隔时间,注意要配置12T模式,因为这样,时钟值的单位就为1μs。
令人兴奋的是,我们只需要拿PCA来记录间隔时间,因此不需要用到它的模块,只需要调用其内部16位定时器即可,配置代码甚至背过就可以。

void us_transmit(); // 软件延时发射

unsigned char us_measure()
{
    CMOD = 0x00; // 配置12T、不使能溢出中断
    CH = 0;	// 装填初始值
    CL = 0;
    
    EA = 0;	// 暂时关闭全局中断
    us_transmit(); // 发射超声波
    EA = 1; // 重新打开全局中断
    
    CR = 1; // 开始PCA计时
    while((us_rx==1) && (CF==0)); // 阻塞监听,任一条件不满足即跳出
    CR = 0; // 跳出了,停止计时
    
    if(CF == 0) // 没溢出
    {
        return ((CH<<8)|CL)*0.017/2; // 返回厘米(cm),此处拦腰斩断小数
    }
    else // 溢出了,数据无效
    {
        CF = 0;	// 手动清除标志位
		return 0;
    }  
}

LED

我们优化了LED的底层驱动函数,使用一个8位字节led_dat和一个LED显示函数led_disp操控LED,通过掩码来设置位、请空位。由于CT107D开发板,8个LED共阳极,因此我们需要统一规范:在应用层,led_dat的位为1代表亮,为0代表灭

// 在应用层
unsigned char led_dat;
// 点亮某位
led_dat |= (1<<0);
// 熄灭某位
led_dat &= ~(1<<0);

// 在底层
void led_disp(unsigned char dat)
{
    static unsigned char old_status = 0x00; // 上电默认全亮
    
    if((~led_dat) ^ old_status) // 如果状态改变
    {
        latch(~led_dat, 4); // 取反
        old_status = ~led_status; // 更新数据
    }
}

蜂鸣器、继电器、电机

模块 偏移 开启状态位 关闭状态位
继电器 4 1 0
蜂鸣器 4 1 0
电机 6 1 0

由于三个模块都在Y5路,因此在控制单个模块的时候需要单独置位。

static unsigned char status = 0xFF; // 上电默认全开
static unsigned char old_status = 0xFF;

void relay(bit enable)
{
    if(enable) // 使能
    	status |= (1<<4); // 置1
    else // 不使能
        status &= ~(1<<4) // 清0
    
    if(status ^ old_status) // 状态改变
    {
        latch(status, 5); // 锁存
        old_status = status; // 更新数据
    }
}

void motor(bit enable)
{
    if(enable)
    	status |= (1<5);
    else
        status &= ~(1<<5)
    
    if(status ^ old_status)
    {
        latch(status, 5);
        old_status = status;
    }
}

void buzz(bit enable)
{
    if(enable)
    	status |= (1<<6);
    else
        status &= ~(1<<6)
    
    if(status ^ old_status)
    {
        latch(status, 5);
        old_status = status;
    }
}