单片机零基础教学,默认c语言有一定基础
适配CSDN Markdown导入格式,复制全部内容,CSDN博客编辑器选择「Markdown」粘贴即可,代码块、表格、层级标题均可正常渲染。
安装软件
- keil 538a
- stm32cubemx 6.8.1
- jlink驱动
- 串口调试助手:xcom
- ch340驱动
驱动怎么确认装好(在哪做):
USB 线插电脑 → 右键"此电脑"→ 管理 → 设备管理器 → 展开"端口(COM 和 LPT)":看到USB‑SERIAL CH340 (COMx)或ST‑Link Virtual COM Port (COMx)即成功
带黄色感叹号 = 驱动没装好,重装对应驱动包
建立调试bug的思路
- 物理层——用万用表测电压、电流、共地
- 没通过:查接线/供电/接地 → 重测
- 接线正常 → 链路层:用逻辑分析仪/示波器抓波形,看有无 START / ACK / STOP
- 没通过(没有波形、没有ACK):根据波形:查上拉、查地址、查共地
- 波形正常 → 驱动层:查时钟使能、GPIO 复用开漏模式、查 I2C 外设时钟
- 没通过:改 CubeMX / 改初始化 → 重测
- 配置正确 → 业务层:查从机地址偏移(左移1位)、寄存器地址、超时时间
- 地址 / 超时错:改代码 → 重测
- 正常:记录根因 + 解决 → 进坑表
- 配置正确 → 业务层:查从机地址偏移(左移1位)、寄存器地址、超时时间
- 没通过:改 CubeMX / 改初始化 → 重测
- 波形正常 → 驱动层:查时钟使能、GPIO 复用开漏模式、查 I2C 外设时钟
- 没通过(没有波形、没有ACK):根据波形:查上拉、查地址、查共地
- 接线正常 → 链路层:用逻辑分析仪/示波器抓波形,看有无 START / ACK / STOP
- 没通过:查接线/供电/接地 → 重测
严格按照顺序:先测电、再查波、后寄存器、最后业务
第0章:什么是外设
0.0:什么是单片机(什么是MCU),理解单片机的运行
1:什么是单片机?
单片机就是将一堆外设全部集成在一个芯片内部,通过外露引脚使用,里面有:
- CPU
- Flash(写代码、烧录固件的地方,断电不丢失)
- SRAM(跑 Flash 中存的程序/固件,断电丢失)
- 各种外设(根据各个芯片的型号决定其功能模块)
- 引脚(根据封装不同,外露引脚数量也不同,将这些引脚焊接在开发板上,和外部传感器连接)
一句话理解就是:CPU + RAM + Flash + 外设 + 一堆引脚
与电脑的区别:体积小、便宜、低功耗、专门干一件确定的事
2:对比电脑,单片机是怎么运行的?
电脑
跑操作系统,由操作系统给每个程序排班、分时抢占CPU单片机
由于RAM太小,养不起操作系统,所以开机之后,只跑主循环中 main() 函数内的 while(1) 死循环
intmain(void){//初始化各种外设接口while(1){//执行各种对外设的操作,永不退出}}当任务多到 while(1) 无法管理,就出现了RTOS(实时操作系统)
3. 程序是怎么让LED亮起来的?
写对应的寄存器
寄存器:就是外设内部一小块"能被地址访问的开关面板"
往某个地址写1:就打开对应的开关
往某个地址写0:就关闭对应的开关
Flash 里存着写的程序 │ ① CPU 按地址从 Flash 取出一条指令(取指) ▼ CPU 执行这条指令:② 比如它要"把 PC13 这根引脚输出高电平" │ ③ CPU 向 GPIO 外设的某个寄存器地址写一个数(写寄存器 = 拨开关) ▼ 寄存器那一位变成 1 ④ 开关合上 │ ⑤ 引脚(PC13)上的电压跟着变高 ▼ LED 两端有了电流 ⑥ 灯亮了// 第 1 步:给 GPIO 这个外设"开电"(否则它的寄存器是锁着的,写不进去) RCC->AHB1ENR |= (1 << 3); // 拨开关:打开 GPIOC 的时钟 // 第 2 步:把 PC13 设成"输出模式" GPIOC->MODER &= ~(3 << 26); GPIOC->MODER |= (1 << 26); // 拨开关:PC13 配成输出 // 第 3 步:让 PC13 输出高电平 GPIOC->ODR |= (1 << 13); // 拨开关:PC13 拉高 → LED 亮上面这个就是CPU底层干的事情,任何芯片都一样
0.1:相关的数模电知识
什么是电压、电流、电阻
- 电压 / 电流 / 电阻
- 电压(V) == 水压
类似于水压,压力越大,电流越大
单片机用 3.3v 做高压 - 电流(I) == 水流
水管里实际流过多少水,对应导线里实际流过多少电,灯亮不亮,就看有没有足够电流流过它 - 电阻(R)== 水管变细的那一段
常用于:限流、分压
管子越细 == 电阻越大,水流越小 == 电流越小
- 高低电平 == 逻辑 1 或 0
高电平:3.3V / 5V = 逻辑1
低电平:0V = 逻辑 0
如果要让某个外接的电子元件工作,需要确保外设的电压和GND之间有电势差,注意:(VCC(高电平)连接 VCC,GND(接地)连接 GND,不能接反,否则会烧坏芯片或外设)
- 引脚电平悬空
如果一根引脚既没接到 3.3V(高电平)、也没接到 GND(低电平),就叫"引脚悬空"(floating)
此时,引脚的电压飘忽不定,可能高,也可能低,不稳定,如何解决?
回答:接上拉电阻/下拉电阻
- 接上拉电阻:在引脚和 3.3V 之间接一颗电阻,把它"钉"在高电平,读到的就是高电平 1
- 接下拉电阻:在引脚和 GND 之间接一颗电阻,把它"钉"在低电平,平时读到的就是低电平 0
如何接上下拉电阻?(以按键为例)
3.3V ──[上拉电阻]──┬── 引脚(平时被拽成 1) │ [按键] │ GND3.3V ──────── 引脚 ──[按键]──[下拉电阻]── GND- 推挽输出 / 开漏输出
推挽输出 = 上下两个开关,一个是:既可以输出高电平,又可以输出低电平,但是不能既输出高电平,又输出低电平
3.3V ──[开关上]──┬── 引脚 │ GND ──[开关下]──┘要输出高:合上上开关、断开下开关(引脚接 3.3V)
要输出低:断上合下(引脚接 GND)
两个开关总有一个合着
开漏输出:只能输出低电平,如果要输出高电平,必须外接上拉电阻
3.3V ──[开关永远断开]──┬── 引脚 │ GND ──[开关]──────────┘0.2:从 C 语言到连接硬件
寄存器 == 芯片里一个带地址的"开关面板"
对于MCU的片上外设,操作硬件的本质就大概等同于改写硬件对应的寄存器
那什么是寄存器?
它就是内存里一个固定地址的数。
比如 STM32 里 GPIOA 这个外设,它的基地址是0x40020000;它里面第一个寄存器 MODER,就在这个地址上。
只要能通过地址读写那个数,就相当于拨动了开关。
C 语言里"按地址访问一块内存"的工具就是指针,一般由厂家定义
#define GPIOA_BASE 0x40020000u /* 外设的门牌地址(查参考手册) */ #define GPIOA ((GPIO_TypeDef *)GPIOA_BASE) /* 把这个地址"假扮"成一个结构体指针 */单寄存器访问:(volatile uint32_t *)GPIOA_BASE
0x40020000u:字面常量,只是一个数字,本身不代表内存访问uint32_t *:强制类型转换,告诉编译器:把这个数字解释成指针,指向一个 32 位无符号变量volatile:修饰这个指针指向的数据。含义:该内存的值可能被硬件异步修改,禁止编译器对这个地址的读写做优化,每次都必须真实访问总线,不能缓存、合并、省略读写。寄存器访问必须加 volatile,否则读取状态标志、连续读寄存器会出错
注意区分:
*((volatile uint32_t *)0x40020000u) = 1;//直接对单个寄存器地址写入 GPIOA->MODER = 0x01;//通过结构体指针访问寄存器组,前提:GPIO_TypeDef结构体里每一个成员都用 volatile 修饰。为什么GPIOA->MODER能行?
因为厂家定义了一个结构体,把所有寄存器按它们的地址偏移排好队:
typedef struct { volatile uint32_t MODER; /* 偏移 0x00 */ volatile uint32_t OTYPER; /* 偏移 0x04 */ volatile uint32_t OSPEEDR; /* 偏移 0x08 */ volatile uint32_t ODR; /* 偏移 0x14 */ /* ...其余寄存器... */ } GPIO_TypeDef;C 语言里结构体成员在内存里是紧挨着按顺序排的,每个成员的偏移量正好对应手册里那个寄存器的地址偏移。
于是GPIOA->MODER就等价于"读 GPIOA_BASE 偏移 0 处的那个寄存器",GPIOA->ODR就是"读偏移 0x14 处的那个"。
结构体只是给地址起了好记的名字,底层还是按地址读写。
位运算:一个寄存器的32位就相当于32个开关
要"只动其中一个、不碰其他",就靠三种位运算:&、|、^
| 操作 | 代码 | 人话 |
|---|---|---|
| 把某位置 1(其他不动) | `reg | = (1u << n);` |
| 把某位清 0(其他不动) | reg &= ~(1u << n); | 只把第 n 个开关断开,其余原样 |
| 翻转某位 | reg ^= (1u << n); | 合上的断开、断开的合上 |
<< n就是"把 1 左移 n 位",制造出"只有第 n 位是 1、其余是 0"的掩码;|=按位或上去,就只影响那一位。
比如点个灯:GPIOA->ODR ^= (1u << 5);(具体的地址需要结合芯片手册去查,这里只做演示)
GPIOA->ODR:找到 GPIOA 的输出数据寄存器(控制引脚电平的那个);1u << 5:1 左移 5 位 =0b00000000_00000000_00000000_00100000,即"只有第 5 位是 1"的掩码;^=:按位异或并写回——第 5 位翻转(原来是 1 变 0、原来是 0 变 1);
合起来:PA5 这根引脚的电平翻转一次,接在 PA5 上的 LED 就闪一下。
从"会写 for 循环"到"会控制引脚"
定义一个变量 int x; → x 存在内存某个地址里 → 用指针就能按地址读写它:*p = 1; → 单片机把"外设寄存器"也映射到内存地址上 → 于是用同一个指针语法读写那个地址,就是拨外设的开关 → 开关合上 → 引脚电压变化 → LED 亮所以,从 C 变量到点亮一颗 LED,中间没有任何新魔法,就是"内存地址 → 指针 → 寄存器 → 引脚"这一条链,后面所有外设(UART、SPI、定时器……)都是同一件事:找到它的寄存器地址,用指针/位运算读写它。
那如何查找寄存器的地址 以及 每一位管什么呢?
查看芯片的RM手册 或者 官方的 手册为主
STM32全系列 寄存器地址+位定义:RM 参考手册(Reference Manual) 内核寄存器(NVIC/SysTick/SCB):PM 编程手册(Programming Manual) 头文件:stm32xxxx.h GD32(兆易) 寄存器地址+位定义:User Manual 用户手册 内核寄存器:Cortex‑M官方PM 头文件:gd32xxxx.h CH32(沁恒) 寄存器地址+位定义:参考手册 内核寄存器:Cortex‑M官方PM 头文件:ch32xxxx.h AT32(雅特力) 寄存器地址+位定义:参考手册 内核寄存器:Cortex‑M官方PM 头文件:at32xxxx.h HK32(航顺) 寄存器地址+位定义:用户手册 内核寄存器:Cortex‑M官方PM 头文件:hk32xxxx.h MM32(灵动) 寄存器地址+位定义:产品手册/用户手册 内核寄存器:Cortex‑M官方PM 头文件:mm32xxxx.h APM32(极海) 寄存器地址+位定义:用户手册 内核寄存器:Cortex‑M官方PM 头文件:apm32xxxx.h CSM32(芯海) 寄存器地址+位定义:用户手册 内核寄存器:Cortex‑M官方PM 头文件:csm32xxxx.h 补充通用 DS(Datasheet 数据手册):只查引脚、电气参数,不查寄存器一句话总结:
外设(Peripheral)= 芯片内部集成的一堆"功能模块",每个模块管一件事:GPIO 管引脚高低电平、UART 管串口收发、TIM 管定时/波形、ADC 管模拟量转数字、I2C/SPI 管接传感器芯片……
从这一章开始,要建立几个基本认知
- 外设 = 寄存器组——每个外设是一块内存映射的寄存器(如 GPIOA 的地址 0x40020000),读写寄存器 = 控制外设;
- CubeMX 帮你写配置代码(HAL 库),但原理必须懂(换国产芯片没有 CubeMX 时全靠原理);
- 查函数的方法比背函数重要一万倍——外设名_动作(如
HAL_UART_Transmit= UART 外设的发送动作)
搭建最小工程
cubemx操作
- 通过MCU选择→搜索你的芯片,比如我的是:stm32f411ceu6→点击星号收藏→双击芯片型号进入配置界面
- 点击:System Core→SYS→RCC→HSE(高速外部时钟)→石英/外部振荡器
必须做,否则晶振会不起振 - 选择引脚:System Core→GPIO→PC13→输出,选好之后,中间会出现你选好的引脚
要看自己的开发板的原理图,看LED究竟连接的哪个引脚 - output level:起始输出电平为低电平(看自己的原理图,看是高电平点亮还是低电平点亮,这里是低电平点亮)
- 模式:推挽输出
- 既不上拉,也不下拉(在推挽模式下,选择上下拉无效,也没有意义)
- 低速(速度越快,MOS管导通电阻越小,压摆率边沿越陡,这里我们使用低速)
- 标签:自己定义,这里我们写LED
- 时钟树:选择HSE作为PLL多路选择器的入口,同时选择PLLCLK作为系统时钟多路选择器的入口,然后在HCLK(MHz)这个框内填写芯片最大主频,然后回车,cubemx会自动帮我们计算其他参数
- 回到GPIO界面,选择我们的USART1 TX 和 USART1 RX 引脚(查芯片原理图)
- 点击Connectivity,选择USART1作为我们的调试口,选择异步模式
- 设置参数为:115200、8、N、1。(这里注意,要看自己的串口的波特率是多少,我的是115200)
- 进入项目管理视图,填写项目名(不能混杂中文和特殊符号)、路径、IDE(这里使用的是MDK)
- 点击code generator,勾选生成独立的.c/.h文件
- 产生工程
- 到自己产生工程的路径,点击MDK‑ARM,双击 .uvprojx 这个后缀的文件打开keil
keil操作
- 进入以后,点击一个像魔法棒的工具→Debug,选择自己的调试器→点击后面的setting→port这一栏看自己用的是JTAG(引脚多,功能也多)接口还是SW(引脚相对较少,功能够用)接口→选好以后→点击Flash Download,勾选重置并复位(避免以后每次下载程序都要按一下板子上的复位键)
- 点击魔法棒→勾选USE MicLIB选项
- 选择好以后,先编译一下,确保0错误0警告,同时让各个文件之间产生链接关系
- 至此我们的最小工程创建成功
main.c
在user code区域添加串口重定向
#include <stdio.h> /* printf 重定向:printf 的每个字符从 USART1 发出去(Keil 微库版) */ int fputc(int ch, FILE *p_file) { (void)p_file; /* 不用文件指针,屏蔽警告 */ HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1u, 100u); return ch; }在 main函数中,使用printf函数输出一串字符:printf("hello world\r\n");
**注意:**我这里使用的是最小系统板,连接的USB转TTL模块,各位以自己手里的开发板为准
如果也使用USB→TTL,务必将模块的TX引脚和开发板的RX引脚交叉连接
输出必须是在HAL库初始化完成之后再输出!
然后编译下载,在串口调试助手中能看到helloworld、开发板的LED亮了即为成功。
0.3:本章学习闭环:回顾、实验、自测、小结
回顾 0.0、0.1、0.2
外设=CPU 外面、挂在总线(AHB/APB1/APB2)上的"功能黑盒",CPU 只能通过"地址→寄存器"读写它,不能直接摸引脚。
学外设的统一四步法:找时钟 → 配引脚复用 → 配控制寄存器 → 等标志位/收中断,后面每一章都是这四步的展开。
三种数据通路:CPU 直读写(最简单)、中断(事件驱动)、DMA(批量搬运不占 CPU)
自测
- 为什么 LED 不亮时第一步是查"时钟开没开",而不是先量电压?
- AHB1 / APB1 / APB2 三条总线上,GPIO、USART1、I2C1、TIM2 分别挂在哪条?
- 数据手册里"fADC=36MHz 上限"这句话约束的是哪个外设的哪个参数?
常见错误
- CubeMX 生成的代码里 GPIO 能亮灯,自己手写的寄存器代码全不亮
检查时钟是否已经打开,"开时钟"永远是固定的第 1 步RCC->AHB1ENR开时钟(外设时钟门关着,寄存器写不进去,不报错) - 以为
GPIOC->ODR=0就是"熄灭"
这个要看自己开发板的原理图,测一下电压,看LED是低电平还是高电平点亮,然后检查引脚设置的是开漏还是推挽 - 把"外设地址"和"引脚号"搞混(以为
0x40020800是 PB 的引脚号)
记住:基地址=外设的门牌,引脚号=门牌里的第几个抽屉
最小实验
目标:确保能将程序烧录进开发板、点亮一个小灯、串口打印
0.4:实现思路+优化思路(方法论)
拿到一个新外设怎么下手
- 画数据通路
信号从哪进(引脚/总线)→ 外设内部谁处理 → 结果去哪(DR/中断/DMA) == 先搞清信号"走的路线图"再写代码,别上来就翻寄存器 - 定时钟
这个外设挂哪条总线、使能时钟、频率多少 == 外设是"关了电的电器",先合上它的电闸(RCC 时钟位)才能用 - 配引脚
输入还是输出;复用还是普通;推挽还是开漏;上拉下拉是否需要? - 写状态机
优化思路
第一版用阻塞(最快跑通)→ 第二版换中断(CPU 解放)→ 第三版换 DMA(批量场景)
0.5:本章小结
外设基础 ├── 什么是外设 → 总线挂的寄存器组 ├── 三类寄存器 → 数据/控制/状态 ├── 地址映射 → 0x40000000 起 ├── 配置三步 → 开时钟→配 GPIO→写寄存器 ├── 常见坑 → 不开时钟写了没反应 └── 面试点 → 为什么先开时钟架构红线:任何示例代码绝不演示在 main 里裸写寄存器代码:
GPIOA->ODR = 1——哪怕只有一行,也必须套上 BSP 的壳:ec_bsp_led_on(LED)