有关单片机的阻塞与非阻塞
大家好,今天是2026年8月2日,建军节刚过1天。今天我想与大家分享的是有关单片机的阻塞与非阻塞。大家都知道裸机开发和实时操作系统开发吧,这两个开发方式有着很大的区别,由于本人当前更多的从事于裸机开发,所以这篇文章我会着重于将有关单片机裸机开发的阻塞与非阻塞,那么废话不多说,接下来我将与大家进行我的分享。
裸机开发,顾名思义,是在一个“赤裸”的单片机中进行开发,直接使用HAL库,寄存器,标准库开发方式,操作单片机底层寄存器从而开启时钟,配置GPIO,配置各种片内外设,使其互相配合成一个整体,实现自己想要的业务操作。这些操作都是我们通过C语言编程,然后编译器转换成机器码烧录到单片机的flash中的,在这个过程中,我们需要非常清楚单片机的运行逻辑,玩过单片机的帅哥美女们都知道,单片机的时钟是他的心脏,所有单片机的操作都受时钟的指挥。只有时钟开启后,单片机才可以工作(要想深入理解可以参考STM32官方技术文档的时钟树文献),每个单片机都有一个系统时钟和多个外设时钟,系统时钟可以通过配置SysTick进行配置,产生特定的系统时钟节拍,也可以通过外设时钟操作定时器,配置装载值和中断使能等,实现定时器的定时操作。
裸机开发还有个十分重要的特点,就是他只在一个while循环里工作,执行用户的业务程序。也就是说如果整个单片机项目程序是一个进程,那么他只有一个线程。若是只有一个线程,那么他的工作就有点要受限制了,他只做一个任务且这个任务只按用户的安排去做。你可以想想你打游戏,系统分配了很多任务,如果你只做单一一个特定的任务,那么是不是就很占用你的时间和资源了,所以说裸机开发最最限制的不妨就是单片机运行的实时性了!这是裸机开发的硬伤,不同于实时操作系统,你可以通过多任务(多线程)串行或并行处理一连串的任务,只需要通过信号量和互斥锁等操作就可以实现多任务并行处理的效果了!
我将以自己业余时间里开发的基于STM32F103C8T6微处理器设计的简易心率检测仪项目,给大家好好分享一下,我在裸机开发中遇到的一些趣事。
以简单按键扫描为例,大多数单片机玩家,可能会使用这种方式,进行单片机按键输入的开发。
代码:
int8_t key_scan(void) { int8_t key_value ; memset(&key_value , 0 , sizeof(uint8_t)) ; is_key_exam_flag = false ; // 按键检测标志位置0,避免导致无效检测 if ((GPIOA->IDR&(1<<8) && GPIOA->IDR&(1<<11) && GPIOB->IDR&(1<<15) && GPIOB->IDR&(1<<14) && GPIOB->IDR&(1<<13) && GPIOB->IDR&(1<<12)) == true) { key_value = PRESS_NO ; } // 没有按键按下 else { delay_ms(20) ; // 按键消抖处理 if (is_key_exam_flag) { if(!(GPIOA->IDR&(1<<8)) ) key_value = PRESS_UP ; else if(!(GPIOA->IDR&(1<<11))) key_value = PRESS_CLR ; else if(!(GPIOB->IDR&(1<<15))) key_value = PRESS_ENT ; else if(!(GPIOB->IDR&(1<<14))) key_value = PRESS_LEFT ; else if(!(GPIOB->IDR&(1<<13))) key_value = PRESS_DOWN ; else if(!(GPIOB->IDR&(1<<12))) key_value = PRESS_RIGHT ; } } // 按键按下 return key_value ; } /* 非阻塞消抖扫描(普通方式) */这是一段很简单的按键扫描程序。我顺便和大家小提一嘴,在单片机开发中,按键按下后,后产生一定的机械振动,在那段期间电平读取不稳定,所以一般需要延时20ms左右,进行按键的消抖处理,经过消抖处理后,单片机输入寄存器(IDR)读取到的电平会相对稳定。在这个按键扫描程序中,我使用了delay_ms(20)进行延时消抖,这样可以有效读取到稳定的电平,但是这样的延时是会空耗CPU单元效率的,从而降低单片机的实时性,我们把这种会空耗CPU效率的延时称为硬延时,也就是系统级延时,在这种延时下,可能会对其他外设的工作造成影响,20ms虽然微乎其微,但是足以撼动一整个项目(针对裸机开发,实际上高手都会使用实时操作系统),如果是大延时就更不用说了,直接影响串口,SPI等硬件通信,导致数据交互异常,就比如你打枪战,你的网络突然来了个延迟掉帧,你被对面爆头了,这感觉想想都难受,不仅错失了战机,也导致了任务的失败,所以在单片机开发里,一定要减少硬延时的使用,但是有些必要的硬延时还是要有的!我曾经一步步走来,吃了很多硬延时的亏,导致我写的项目,总是站着空耗,耗尽了CPU的青春......
针对这一现象,或许聪明的小伙伴就思考了,“诶,既然你这么说,那么我只让程序做延时不就好了吗?还要系统延时?!还硬延时!?”,既然硬的不行,就来软的嘛!恭喜你,你想对了,但是软延时,真的能做到精确延时吗?答案是不一定!大家都知道,时钟频率越快,指令执行速度,代码运行能力就越好!那么为何不写个循环做计数处理延时呢?事实上,这样的延时是很不稳定的,举个例子。
for (int i = 0 ; i < 10000 ; i++) ; // for int i = 0 ; while (i < 10000) i++ ; // while在上面代码中,我们使用到了for和while循环,在这种循环里,单片机需要判断条件,并循环执行循环中的语句,虽然全都利用到了CPU,没有造成他的空耗,但是这样的延时,未免太不追求稳定性了,这是无法投入到正式使用中的,你可以想象你正在做一盘菜给自己的另一半吃,你真的敢完全凭自己的感觉,不用靠计时器计时去把控一道菜的烹饪时间吗,我想你也不敢吧,一道好菜是注重烹饪时间的,太早了入不了味,太迟了又老了,所以我们应该对时间有一定的概念,不要太把自己交给时间,上大学的朋友更是如此,一定要抓紧时间学习!要有自己独当一面的能力!
为此,我将向大家隆重介绍一下,单片机开发的好帮手!单片机大大大管家!单片机时间把握的严父!定时器将军!
定时器属于单片机片内外设,挂在PCLK时钟下,具备定时中断溢出的功能,有一定单片机基础的朋友都知道,中断这一东西的好处,它可以在达到一定中断条件后,保留现场,执行中断事件后,在回来。定时器中断亦是如此(前提是你要明白中断是什么?我相信聪明的你,多敲几遍代码就知道了【狗头】)
按键外设头文件
#ifndef _KEY_H #define _KEY_H #include "stdio.h" #include "stdint.h" #include "stdbool.h" #include "string.h" #include "stm32f10x.h" #include "delay.h" #include "timer3.h" #define PRESS_NO 0x00 // 无按键按下 #define PRESS_CLR 0x01 // 返回键按下 #define PRESS_UP 0x02 // 上键按下 #define PRESS_ENT 0x03 // 进入键按下 #define PRESS_LEFT 0x04 // 左键按下 #define PRESS_DOWN 0x05 // 下键按下 #define PRESS_RIGHT 0x06 // 右键按下 // 按键赋值表 enum KEY_STATUS { KEY_READY = 0, // 按键检测准备(无输入) KEY_EXAM = 1 // 按键输入检测(输入中) } ; // 按键状态 extern bool is_key_init_flag ; // 按键初始化标志 void key_init(void) ; // 按键外设初始化 int8_t key_scan(void) ; // 按键扫描 bool print_key_value(int8_t *key_value) ; // 获得当前按键状态(可调试用) #endif按键外设源文件
#include "key.h" const char *key_str_buf [] = { "PRESS_NO " , "PRESS_CLR " , "PRESS_UP " , "PRESS_ENT " , "PRESS_LEFT " , "PRESS_DOWN " , "PRESS_RIGHT" } ; // 按键字符串数组(可调使用) const int8_t key_index_min = 0 ; // 按键最小值 const int8_t key_index_max = (int8_t)(sizeof(key_str_buf) / sizeof(key_str_buf[0])) - 1 ; // 按键最大值 bool is_key_init_flag = false ; void key_init(void) { RCC->APB2ENR |= 0x01 << 2 ; GPIOA->CRH &= 0xffff0ff0 ; GPIOA->CRH |= 0x00003003 ; GPIOA->ODR &= 0xf6ff ; GPIOA->ODR |= (1<<8) | (1<<11) ; // 初始化 PA11(KEY_CLR) && PA8(KEY_UP) RCC->APB2ENR |= 0x01 << 3 ; GPIOB->CRH &= 0x0000ffff ; GPIOB->CRH |= 0x33330000 ; GPIOB->ODR &= 0x0fff ; GPIOB->ODR |= (1<<15) | (1<<14) | (1<<13) | (1<<12) ; // 初始化 PB15(KEY_ENT) && PB14(KEY_LEFT) && PB13(KEY_DOWN) && PB12(KEY_RIGHT) is_key_init_flag = true ; } int8_t key_scan(void) { static int8_t key_status = KEY_READY ; /* 按键实时状态 */ int8_t key_value ; memset(&key_value , 0 , sizeof(uint8_t)) ; /* 按键输入数值 */ switch (key_status) { case KEY_READY : { if ((GPIOA->IDR&(1<<8) && GPIOA->IDR&(1<<11) && GPIOB->IDR&(1<<15) && GPIOB->IDR&(1<<14) && GPIOB->IDR&(1<<13) && GPIOB->IDR&(1<<12)) == true) { key_value = PRESS_NO ; key_status = KEY_READY ; } // 没有按键按下 else key_status = KEY_EXAM ; break ; } case KEY_EXAM : { if (is_key_exam_flag) { if(!(GPIOA->IDR&(1<<8)) ) key_value = PRESS_UP ; else if(!(GPIOA->IDR&(1<<11))) key_value = PRESS_CLR ; else if(!(GPIOB->IDR&(1<<15))) key_value = PRESS_ENT ; else if(!(GPIOB->IDR&(1<<14))) key_value = PRESS_LEFT ; else if(!(GPIOB->IDR&(1<<13))) key_value = PRESS_DOWN ; else if(!(GPIOB->IDR&(1<<12))) key_value = PRESS_RIGHT ; key_status = KEY_READY ; } else key_status = KEY_READY ; break ; } default : key_status = KEY_READY ; break ; } return key_value ; } /* 非阻塞消抖扫描(状态机方式) */ bool print_key_value(int8_t *key_value) { if(key_value == NULL) { printf("error key\r\n") ; return false ; } if(*key_value < key_index_min || *key_value > key_index_max) { printf("invalid key\r\n") ; return false ; } printf("KEY : %s\r\n" , key_str_buf[*key_value]) ; return true ; }定时器头文件
#ifndef _TIMER3_H #define _TIMER3_H #include "stdbool.h" #include "stm32f10x.h" #include "sys.h" extern bool is_timer3_init_flag ; // 定时器初始化标志位 extern bool is_key_exam_flag ; // 按键检测标志位(定时器非阻塞检测) extern uint16_t second_count ; // 定时器中断溢出计数(20ms自增1) void TIM3_Init(u16 arr,u16 psc) ; // 定时器初始化 bool clock_trigger(int interval_time_second) ; // 时钟触发函数 #endif定时器源文件
#include "timer3.h" bool is_timer3_init_flag = false ; bool is_key_exam_flag = false ; uint16_t second_count = 1 ; // 第1s时刻 void TIM3_Init(u16 arr,u16 psc) { RCC->APB1ENR|=1<<1; //TIM3时钟使能 TIM3->ARR=arr; //设定计数器自动重装值 TIM3->PSC=psc; //预分频器设置 TIM3->DIER|=1<<0; //允许更新中断 TIM3->CR1|=0x01; //使能定时器3 MY_NVIC_Init(0,3,TIM3_IRQn,2);//抢占0,子优先级3,组2 is_timer3_init_flag = true ; } // 定时器初始化 void TIM3_IRQHandler(void) //TIM3中断 { if(TIM3->SR&0X0001)//溢出中断 { is_key_exam_flag = true ; // 20ms中断扫描一次按键输入 second_count++ ; // 20ms中断溢出自增1次 } TIM3->SR&=~(1<<0);//清除中断标志位 } // 定时器中断服务函数 bool clock_trigger(int interval_time_second) { interval_time_second *= 50 ; // 定时器3 20ms触发一次中断溢出,故乘以50表示1s定时间隔 static bool trigger_flag = false ; // 触发标志位 static bool refresh_flag = true ; if (refresh_flag) { second_count = 1 ; refresh_flag = false ; } // 上电刷新定时器中断溢出计数(仅1次刷新) if (second_count % (interval_time_second + 1) == 0) { trigger_flag = true ; second_count = 1 ; } else { trigger_flag = false ; } // 1s定时检测,检测到边沿后触发时钟中断 return trigger_flag ; // 返回触发标志位 }主程序(无业务程序,仅供参考)
/****************************************** Designer : @YiZhou Date : 2026/07/11 Brief : heart exam Version: V0.1 Special Thanks : @ZDYZ *******************************************/ /* C语言库 */ #include "stdio.h" #include "stdint.h" #include "stdbool.h" #include "string.h" /* APP库 */ #include "app.h" #include "app_data.h" /* 系统硬件驱动库 */ #include "sys.h" #include "stm32f10x.h" #include "delay.h" #include "timer3.h" /* 硬件通信库 */ #include "usart.h" #include "i2c1.h" /* 外设库 */ #include "key.h" #include "buzzer.h" #include "at24c02.h" int8_t g_key_value ; // 全局按键 int main () { Stm32_Clock_Init(9) ; delay_init(72) ; TIM3_Init(199, 7199); // 20ms uart_init(72,115200) ; IIC_Init() ; key_init() ; buzzer_init() ; AT24CXX_Init() ; print_app_version() ; print_app_status_hardware() ; while(1) { // user_code } }我主要和大家分享一下,我对于定时器开发的一种技巧使用,以下是我的操作步骤
1.编写定时器头文件,声明定时器初始化和定时器中断函数,声明一个外部定时器计数变量(使用extern声明),声明一些自己项目中需要的外设操作标志位(这里我声明了一个按键操作标志位)
2.编写定时器源文件,编写定时器初始化和定时器中断函数(可以更具需要编写额外的业务函数),定义定时器计数变量(这种定时器技术变量可以全局使用!)
3.在定时器中断函数里编写自己想要的实现中断溢出执行事件
作为过来人提醒大家,不管是定时器中断也好,串口中断也罢,在中断服务函数里尽量少写代码,尽量控制一种外设的工作状态,或者简单的变量递增(计数),不要把串口解析等非常占用时间的东西写在里面,这样的中断是有风险的,我不建议你们这么做!通常也不会这样做。
更关键的一点!定时器是不占用CPU的,但是定时器中断会抢占CPU,这就是我们常说的抢占式处理逻辑了,抢占式处理是具备优先级的,优先级越高,响应越快,执行顺序越靠前。你可以大胆放心地使用,总比你写软延时处理和系统硬延时处理要好的多,至于效果如何,你可以自己去尝试使用定时器定时读取数值,或简单点,定时串口输出内容就好,你会发现,不管你怎么按,单片机总能快速响应,并且输出很快,如果你有一个由按键控制的蜂鸣器,你会发现,你一直按着按键,他是一直响的,中间没有明显隔断,但是采用了硬延时,你会明显察觉到那种“断层感”
如果你想观察这种效果,你可以打开一个定时器,然后在while循环里让串口定时输出一个句子,再在串口输出这条语句前系统硬延时2s,你会发现定时器控制下的串口发送被系统硬延时强制延迟了2s,但是2s一过就又正常定时发送了,这很正常,因为在系统节拍下(SysTick),系统硬延时的优先级是绝对最高的
while (1) { delay_ms(1000) ; delay_ms(1000) ; if (clock_trigger()) { usart_send() ; } ... // 其他业务程序 }如果你喜欢裸机开发,但是又不想空耗久等,影响项目的运行效率,你可以好好考虑学习一下定时器和中断,并且好好参与这个单片机裸机程序的数据流设计和架构设计,这样可以让你的单片机裸机程序,也有一定的实时操作系统的效果,但这两者肯定是没有可比性的!
单片机裸机开发,不是潮流,若是想要往高阶发展,一定要学会实时操作系统,拥有非阻塞思想,这样会对于你日后的嵌入式开发十分有帮助!若是往网络,物联网,AI方向发展,更要学会linux下的pthread编程,相信我,孩子,这会对你很有帮助,linux操作系统是最适合做服务器开发的系统。
总而言之。你一定要学会非阻塞编程思想,他会让你更上一层楼,字面意思上,阻塞就会阻碍你的脚步了。