嵌入式智能门锁项目学习笔记(一)—— 标准库扫盲与实战入门

温馨提示:内容源自米醋电子工作室培训课,由本人总结

写在前面:本次开发,我们将使用 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点灯准备

  1. 拿出硬件:使用一根母对母的杜邦线,将开发板上 LED1 的引脚和 PC2 引脚(属于GPIOC组的第2号引脚)连接起来。
  2. 建立工程:在你电脑的专属工作区新建一个名为“第一讲”的文件夹。打开 MounRiver Studio -> 文件 -> 新建 MounRiver 工程 -> 选择 CH32V307 芯片,具体型号选 VCT6 -> 取消勾选“使用解决方案位置” -> 路径指向刚建立的文件夹 -> 工程命名为 V1.15
  3. 编译与串口测试:点击完成后,系统会自动生成基础代码。找到 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秒钟 为周期交替闪烁。这就意味着,单片机现在既可以在主循环里处理别的计算任务,又能靠后台闹钟完美控制这盏灯了!

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐