嵌入式智能门锁项目学习笔记(一) 标准库扫盲
嵌入式智能门锁项目学习笔记(一)—— 标准库扫盲与实战入门
温馨提示:内容源自米醋电子工作室培训课,由本人总结
写在前面:本次开发,我们将使用 MounRiver Studio 作为主力开发软件,主控芯片为沁恒的 CH32V307 系列。万丈高楼平地起,这篇笔记将带你理清底层逻辑,通过点灯和定时器实战,迈出嵌入式开发的第一步!
零、前言:打好核心概念的地基
在写代码之前,我们要先了解单片机内部是如何工作的,这就像是了解我们即将入住的新房子的户型。
1. 计算机的存储结构(“书房”理论)
计算机存储数据的过程,就像是你在书房里处理文件。为了提高效率,存储器被分为了不同级别:
- 寄存器(Register):访问速度最快,CPU的“办公桌面”。你需要立刻处理的数据都会放在这里,随手就能拿到,不需要起身。
- 缓存(Cache):访问速度介于寄存器和内存之间,CPU的“抽屉”。桌面放不下时,常用的文件就放在手边的抽屉里,拿取也很迅速。
- 内存(Memory):访问速度最慢,CPU的“大书架”。这里用来临时存放大量的数据和程序,空间大但是每次找东西都需要走过去拿。
2. 开发方式对比(“造车”理论)
如果把写程序比作制造一台交通工具,目前主流的开发方式有以下三种:
- 寄存器开发:“徒手打铁造零件”。直接操作硬件底层的寄存器。运行效率极高,但需要查阅厚厚的数据手册去配置一个个二进制位,难度大,开发周期长。
- 标准库开发:“买标准件组装自行车”。官方把底层的寄存器操作封装成了易于阅读的标准函数(比如叫你向左转,你不用管轮子具体转几度)。比寄存器更抽象,易用性好,是新手入门的最佳选择。
- HAL库开发:“全自动驾驶汽车”。HAL(硬件抽象层)提供了极高层次的封装,屏蔽了绝大多数硬件细节。开发起来极其简单快速,但对于理解底层硬件原理可能不够直接。
3. 结构体:超级数据收纳箱
在C语言中,结构体(Struct)本质上是在内存中开辟的一段连续空间,用来打包存储多种数据。
- 它和数组的渊源:你可以把数组看作是一个药盒,里面每一个小格子的形状大小完全一样(数据类型必须相同)。而结构体则是一个多功能收纳箱,里面既可以放衣服(整型),也可以放鞋子(字符型),内部变量的类型可以各不相同。
- 地址偏移机制:以数组
num[1]为例,num就像是药盒最左边那个格子的绝对坐标(首地址),而[1]就像是指令“向右数1格”(偏移量)。结构体在底层也是通过“首地址 + 偏移量”来找到内部特定变量的。
一、GPIO:单片机的“手”和“脚”
GPIO 全称是 General Purpose Input Output(通用输入/输出端口)。
- I (Input):输入,就像单片机的“耳朵”和“眼睛”,用来感知外部世界(比如读取按键是否按下、读取传感器数据)。
- O (Output):输出,就像单片机的“手”和“嘴”,用来影响外部世界(比如点亮LED灯、驱动电机转动)。
1. 实战:通过GPIO点灯准备
- 拿出硬件:使用一根母对母的杜邦线,将开发板上 LED1 的引脚和 PC2 引脚(属于GPIOC组的第2号引脚)连接起来。
- 建立工程:在你电脑的专属工作区新建一个名为“第一讲”的文件夹。打开 MounRiver Studio -> 文件 -> 新建 MounRiver 工程 -> 选择
CH32V307芯片,具体型号选VCT6-> 取消勾选“使用解决方案位置” -> 路径指向刚建立的文件夹 -> 工程命名为V1.15。 - 编译与串口测试:点击完成后,系统会自动生成基础代码。找到
User -> main.c编译并下载。打开串口助手(如STC-ISP),扫描并选择对应串口,波特率设置为 115200,接收缓冲区设为“纯文本模式”。按下板子上的 RESET 键,你会看到类似下面单片机发来的“打招呼”信息:
[00:07:49.168]接收←SystemClk:96000000
ChipID:30700568
This is printf example
2. GPIO初始化:点亮LED的前奏
我们要让单片机工作,必须先配置好相关的参数。将官方例程中的 GPIO 初始化代码复制到我们的 main.c 中。
原始代码展示及详细注释如下:
int main(void)
{
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);// 设置中断优先级分组,相当于给排队的任务发不同颜色的VIP卡
SystemCoreClockUpdate();// 更新系统时钟配置
Delay_Init();// 延时函数初始化,让单片机学会“倒计时等待”
USART_Printf_Init(115200); // 串口打印初始化,设置通讯波特率
printf("hello World\r\n"); // 向电脑发送一句问候
GPIO_InitTypeDef GPIO_InitStructure = {0};// 定义一个叫GPIO_InitStructure的结构体收纳箱,并清零初始化。主要用来传递配置参数
// 下面是原版代码的配置(针对GPIOA, Pin0)
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 开启GPIOA的时钟。单片机的外设要想工作,必须先给它“供血(时钟脉冲)”
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; // 选定要配置的引脚为 0号引脚
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 配置为“推挽输出”模式(一种输出高低电平能力都很强的模式)
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // 设置引脚电平翻转的最高速度为50MHz,暂时按标准写即可
GPIO_Init(GPIOA, &GPIO_InitStructure); // 把上面填好的“收纳箱”地址交给初始化函数,让配置真正生效
while(1)
{
// 主循环,单片机会在这里不断循环执行任务
}
}
3. 代码改造:适配我们的PC2引脚
因为我们实际上把灯接在了 PC2 引脚(属于 GPIOC 的第 2 号引脚),所以必须将上面的代码“偷梁换柱”:
RCC_APB2Periph_GPIOA改为RCC_APB2Periph_GPIOC(换电源通道)。GPIO_Pin_0改为GPIO_Pin_2(换引脚号)。GPIO_Init传入的参数GPIOA改为GPIOC。
修改后的点灯及闪烁逻辑代码:
GPIO_InitTypeDef GPIO_InitStructure = {0};// 定义结构体,用来传参
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);// 打开APB2总线上GPIOC的时钟(给GPIOC供电/时钟)
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2; // 指定操作PC2引脚
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 设置为推挽输出模式
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // 引脚翻转速度
GPIO_Init(GPIOC, &GPIO_InitStructure); // 执行初始化
// --- 下面的代码放在 while(1) 循环内即可实现闪烁 ---
GPIO_SetBits(GPIOC, GPIO_Pin_2); // 输出高电平(点亮或熄灭,取决于LED的接线极性)
Delay_Ms(500); // 傻瓜式等待500毫秒
GPIO_ResetBits(GPIOC, GPIO_Pin_2); // 输出低电平(点亮或熄灭)
Delay_Ms(500); // 傻瓜式等待500毫秒
将上述程序烧录到板子上,你就能看到 LED1 听话地以 0.5 秒为间隔闪烁了!
二、定时器与中断配置:给单片机上个“闹钟”
前文使用 Delay_Ms(500) 进行延时叫做“死等”(阻塞延时),这期间单片机什么活儿都干不了。为了让单片机更智能,我们需要引入定时器(闹钟)**和**中断(突发事件处理机制)。
比喻时间:
什么是中断?假设你正在专心打游戏(主循环while(1)),突然门铃响了(产生中断请求)。你不得不暂停游戏,去开门拿快递(执行中断服务函数)。拿完快递后,你回来继续打刚刚暂停的游戏(恢复主循环)。这就是中断!
1. 寻找与移植定时器代码
我们从官方给的“乡村智居系统”例程中,找到定时器2(TIM2)的初始化代码,直接“拿来主义”。
原始TIM2初始化代码:
void Tim2_Init(u16 arr,u16 psc)
{
TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStruct; // 定时器基础配置结构体
NVIC_InitTypeDef NVIC_InitStructure; // 中断控制器配置结构体
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // 打开TIM2的外设时钟
TIM_TimeBaseInitStruct.TIM_ClockDivision=0;// 时钟分割(一般不用管)
TIM_TimeBaseInitStruct.TIM_CounterMode=TIM_CounterMode_Up;// 设置为向上计数(从0数到设定值)
TIM_TimeBaseInitStruct.TIM_Period=arr;// 自动重装载值(数到这个值就响闹钟)
TIM_TimeBaseInitStruct.TIM_Prescaler=psc;// 设置预分频值(调节计数的快慢)
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseInitStruct); // 初始化定时器
TIM_ITConfig(TIM2, TIM_IT_Update|TIM_IT_Trigger, ENABLE); // 开启定时器的更新和触发中断请求
NVIC_InitStructure.NVIC_IRQChannel=TIM2_IRQn; // 告诉中断控制器,我们要管的是TIM2的中断通道
NVIC_InitStructure.NVIC_IRQChannelCmd=ENABLE; // 开启通道
NVIC_Init(&NVIC_InitStructure); // 初始化中断控制器
TIM_Cmd(TIM2, ENABLE); // 拍下定时器的启动开关,开始倒计时!
}
注:对于初学者,暂时不需要看懂里头每一行代码的死理,把它当做一个“黑盒”工具,知道怎么改参数为我们所用即可。
2. 代码改装:TIM2 变身 TIM3
为了不和已有工程冲突或根据硬件需要,我们把定时器2改成定时器3:
只需要把函数名改为 Tim3_Init,并把函数内所有的 TIM2 都改为 TIM3 即可。
同时,我们还需要一个处理中断事件的中断服务函数(处理拿快递的逻辑):
/*任务控制定时器中断,1ms*/
void TIM3_IRQHandler(void)
{
// 判断确实是TIM3的更新(溢出)中断响了
if(TIM_GetITStatus(TIM3, TIM_IT_Update)!=RESET)
{
// 这里写你要处理的任务(比如计数、翻转电平)
}
// 处理完任务后,一定要清除中断标志位(关掉闹钟),否则单片机会卡死在这里一直“拿快递”
TIM_ClearITPendingBit(TIM3, TIM_IT_Update);
}
注意一个大坑:和传统的 51 单片机使用
interrupt关键字直接声明不同,沁恒的 RISC-V 内核有一套自己独特的机制。为了让定时器中断响应更迅速,我们需要在代码里加一句“快速中断声明”(暂时不用深究底层寄存器压栈原理,当做咒语记住就行):
void TIM2_IRQHandler(void) __attribute__((interrupt("WCH-Interrupt-fast")));//定时器2快速中断声明,由于我们改了TIM3,如果使用TIM3也要对应声明TIM3_IRQHandler!
3. 揭秘底层数学:如何配置出 1ms 的闹钟?
接下来,我们在 main.c 的 GPIO 初始化代码之后,调用我们的定时器初始化函数。
im3_Init(1000,96-1); // 注:此处作者原代码漏写了首字母'T',实际编译时应为 Tim3_Init(1000, 96-1);
硬核科普:这里的 1000 和 96-1 是怎么实现 1ms 定时的?
我们的系统主频心脏跳动速度是 96兆赫兹(96MHz)。这里的“兆”代表科学计数法里的百万(1,000,000),即 96×1,000,000=96,000,00096 \times 1,000,000 = 96,000,00096×1,000,000=96,000,000 赫兹。
- 预分频器(PSC = 96-1):它的作用是给系统心脏“减速”。因为从 0 开始计数,所以 96-1 代表把 96MHz 降低 96 倍。分频后,定时器实际计数频率变成了 96,000,000/96=1,000,00096,000,000 / 96 = 1,000,00096,000,000/96=1,000,000 赫兹,也就是 1MHz。
- 1MHz 的概念:就是每秒钟数 1,000,000 下,换算下来,每数一下就是 1 微秒(1us)。
- 自动重装载值(ARR = 1000):既然数一下是 1us,那我们让单片机数 1000 下再响闹钟,1000 * 1us = 1000us = 1毫秒(1ms)!
至此,我们完美实现了一个每过 1ms 就会触发一次的中断。
4. 终极测试:告别死等的优雅闪烁
我们在代码的最上方定义两个全局变量:
unsigned int time1000ms; // 用来统计1ms触发了多少次的计数器
unsigned char led_flag; // 记录LED当前状态的标志位(0或非0)
// PS:标准C语言在此环境下没有直接的 bit 类型变量,用 unsigned char 替代即可。
接下来,我们把“拿快递”的具体动作写进定时器 3 的中断服务函数里:
/*任务控制定时器中断,1ms*/
void TIM3_IRQHandler(void)
{
if(TIM_GetITStatus(TIM3, TIM_IT_Update)!=RESET)
{
// 每次进入中断(经过了1ms),time1000ms就加1。加到1000说明经过了1000ms(即1秒)
if(++time1000ms == 1000)
{
time1000ms = 0; // 重新清零,为下个1秒做准备
led_flag = !led_flag; // 标志位取反:0变1,1变0
if(led_flag) // 根据标志位决定亮还是灭
{
GPIO_SetBits(GPIOC, GPIO_Pin_2);
}
else
{
GPIO_ResetBits(GPIOC, GPIO_Pin_2) // 注:此处作者原代码行尾缺少了一个分号 ';',编写时需注意补充
}
}
}
// 任务执行完毕,清除中断挂起标志位
TIM_ClearITPendingBit(TIM3, TIM_IT_Update);
}
最终效果:编译并烧录后,单片机的主循环 while(1) 里面即便什么都不写(不进行阻塞死等),LED灯也会极其精准地以 1秒钟 为周期交替闪烁。这就意味着,单片机现在既可以在主循环里处理别的计算任务,又能靠后台闹钟完美控制这盏灯了!
更多推荐



所有评论(0)