核心参数的理解

  • 工作频率(1MHz即每秒执行1百万个指令周期): 意味着处理能力
  • 闪存(1MB):意味着可以存储更多的代码库
  • SRAM(128K):意味着程序运行时可使用的堆栈、缓冲区大小

常规电平(还有其他种类的电平)

5V系统 高:2~5V 低: 0~0.8V
3.3V系统 高: 2~3.3V 低: 0~0.6V
对于GPIO来说,3.3V输出1,0V输出0

推挽和开漏

基础知识

STM32芯片如何被设计出来?(在有内核情况下)

查看数据手册的存储器映射图(芯片厂商将不同的功能分配不同的地址空间),得到所有寄存器(有特定功能的存储单元)的功能及其地址。
由于stm32有4G的寻址能力,理论上可以设计4G的内容。

库函数如何封装出来的?

以GPIO口为例,GPIO是设计在外设段,Peripherals中,先拿外设基地址0x4000 0000

外设上会分多个总线,而GPIO定义在高速总线APB2上,拿到偏移地址0x0001 0000

GPIO 又会有ABCD多个组,拿到A组的偏移地址0x0000 0800

A组又会有多个32位寄存器,拿到ODR寄存器偏移地址0x0000 000C

这样是否意味着所有口都要定义一次??
当然不是,我们利用结构体内存是连续分配的特点来解决。将GPIOA等每个组定义为一个结构体,这样它下面的每个寄存器都可以用->ODR的方式来访问相对位置

使用移位运算符操作寄存器的某一个位

想要改变哪个位,就改0这个位置即可
GPIOB_ODR & = ~(1<<0); 置0
GPIOB_ODR | = (1<<0);置1

波特率

每秒钟传输的位数,9600表示每秒传输9600位
意义

  • 规定波特率,可以让串口传输的收发端一致,正常接受数据

引脚速度

逻辑电平之间切换的速度
意义

*

引脚速度和波特率的关系

疑问

  • 在串口传输中,波特率意味着心跳的速度,要求引脚速度需要赶上心跳的速度,为什么?
    举例: I2C使用400KHz,每个周期为2.5微秒,如果引脚速度选择2MHz,每个信号的上(下)沿占0.5微秒,也就是还剩1.5微秒可以维持高和低电平,这个时间已经足够。
    I2C中,我们必须保证在一个心跳内完成一个完整的周期操作,因此引脚速度需要“赶上”心跳速度。

引脚速度必须留有足够的余量(有各种如电容电感,外部干扰)才能赶上心跳的速度。

上拉电阻的作用

一、新建一个kile5工程的流程

  1. 导入启动文件startup_stm32f10x_md.s ,一般由厂商提供,负责初始化MCU,里面包括复位中断的代码
  2. 勾选target下的use Micro LIB,让启动文件可以调用软件提供的_main复位后调用的函数然后顺序调用C中的main

一、亮灯

明确需要找的开关有哪些

  1. RCC外设时钟使能寄存器,挂载在总线上,需要找到总线基地址,再找到RCC的地址,再找到RCC的时钟使能寄存器的偏移地址
  2. GPIO,挂载在总线上的地址,再找GPIO中A组的偏移。
  3. 找到所需要配置的引脚模式,输出类型,速度,上下拉,读写寄存器的偏移寄存器
  4. 找到寄存器中和自己需要操作的引脚的对应的位,比如说GPIOA0对应的是0和1这两位,操作属于那个口的两位

寄存器方法:

  1. 查找原理图,拿到控制灯D1的口为GPIOA1
  2. 查找参考手册中存储器映射图,可知道GPIO口被设计在APB2上,拿到总线基地址APB2PERIPH_BASE 0x4001 0000
  3. 查找参考手册中存储器映射图,拿到GPIOA口相对于总线的偏移地址相加后得 0x4001 0800
  4. 查找参考手册中GPIO寄存器描述,拿到GPIOA口ODR寄存器的偏移地址0x0C,相加得0x4001 080C,并查看寄存器位数和情况
  5. 在c中使用匿名指针来给内存单元赋值,这里赋值4位16进制数(前2位保留值,后2位控制16个寄存器
1
* (unsign int *)0x4001 080C | = (1<<1);

这里只是简单的将GPIOA中的ODR寄存器的位1置位1。需要亮灯还要设置CRL寄存器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// STM32F1xx头文件
#include "stm32f10x.h"

// 定义GPIOA基地址和相关寄存器的偏移量
#define GPIOA_BASE (0x40010800)
#define GPIO_CRL_OFFSET (0x00)
#define GPIO_ODR_OFFSET (0x0C)

// 定义GPIOA的寄存器地址
#define GPIOA_CRL (*(volatile unsigned int *)(GPIOA_BASE + GPIO_CRL_OFFSET))
#define GPIOA_ODR (*(volatile unsigned int *)(GPIOA_BASE + GPIO_ODR_OFFSET))

// 定义RCC基地址和相关寄存器的偏移量
#define RCC_BASE (0x40021000)
#define RCC_APB2ENR_OFFSET (0x18)

// 定义RCC的寄存器地址
#define RCC_APB2ENR (*(volatile unsigned int *)(RCC_BASE + RCC_APB2ENR_OFFSET))

// 定义位掩码
#define PA0 (1 << 0)
#define GPIOAEN (1 << 2)

void enable_gpioa_clock() {
// 使能GPIOA时钟
RCC_APB2ENR |= GPIOAEN;
}

void configure_gpioa0_output() {
// 配置PA0为通用推挽输出,最大速度为50MHz
GPIOA_CRL &= ~(0xF << (4 * 0)); // 清除PA0的配置位
GPIOA_CRL |= (0x3 << (4 * 0)); // 设置MODE0为50MHz输出模式
GPIOA_CRL &= ~(0x3 << (2 + 4 * 0)); // 设置CNF0为通用推挽
}

void turn_on_led() {
// 设置PA0为高电平,点亮LED
GPIOA_ODR |= PA0;
}

void turn_off_led() {
// 设置PA0为低电平,熄灭LED
GPIOA_ODR &= ~PA0;
}

int main(void) {
// 使能GPIOA时钟
enable_gpioa_clock();

// 配置PA0为输出模式
configure_gpioa0_output();

// 点亮LED
turn_on_led();

// 主循环
while (1) {
// 可添加其他逻辑
}

return 0;
}

由此可知,如果使用寄存器编程,即便只是亮个灯,我们的代码也会非常长,因此,需要将相应的功能封装。当然,我们自己封装也很累,因此需要用官方封装好的函数,这就是库函数编程。

位带操作

在stm32中,有些区域的每个位都被映射到另一个地址,成为位带和位带别名区。通过这些地址可以精准高效地访问那个位

具体映射规则:每一个位都映射到了一个4个字节的完整区域,也就是说在位带区的一个位,在位带别名区需要占32位

计算公式: 位带别名区地址 = 位带别名基地值 + (字节地址 32) +(位偏移4)

GPIO专属结构体

32标准库提供了GPIO可以对齐各寄存器的结构体GPIO_TypeDef,因此可以定义

1
2
#define GPIOF   (GPIO_TypeDef*)GPIOF_BASE 
GPIOF.ODR |= (3<<9);

编译器优化等级

意义

  • 提供了将C代码翻译成汇编的不同优化策略

优化可能会对整个C代码的指令顺序都有影响。

典例

  • 当我们需要分部调试,应该让优化等级为O0
  • 当我们需要精简代码,减少指令,应该让优化等级02或者更高

引出:

  • 优化等级是全局的,但是我们希望局部精细控制优化等级,因此我们有关键字__attribute__((optimize(“Ox”)))控制函数的优化等级为Ox
  • 在变量的优化等级中,使用volatile关键字防止优化,确保每次对变量的操作都是真实的内存操作

volatile关键字

意义

  • 为变量提供可见性,即每次访问到真实的内容

典例

  • 硬件寄存器的值可能随时被硬件改变
  • 多线程或多任务系统中,某个变量可能在不同上下文中被修改。
  • 内存映射的外设寄存器。

在并发编程中我们使用原子操作,互斥锁等等,这些已经包含了可见性的操作,因此不需要volatile关键字

输入模式

意义

  • 为输入设备(比如按键)提供了不同的引脚默认策略,让逻辑更稳定
  1. 浮空输入:啥也不干,缺省,适合有明确的信号源的情况,否则可能会因为噪声或者电磁干扰导致信号不确定
  2. 上下拉:默认电平设置为逻辑1或者0
  3. 模拟: 禁用数字缓冲,接收模拟信号

输出模式

意义

  • 为输出设备(比如LED)提供了不同的引脚默认策略,让逻辑更稳定
  1. 开漏输出:只有一个NMOS管,导通接地,截止高阻态,因此需要上拉电阻才能输出高电平,也就是一般来说需要接上拉电阻,默认高电平
  2. 推挽输出,NMOS和PMOS管一个开一个关,一推一挽,适合于可以独立输出高低电平的情况,也可以接上下拉。最为常用。

标准外设库手册

查看示例代码可以在Directories(目录)下的Examples找到示例

中断

外部中断和内部中断

EXTI 外部中断

意义

  • 提供了统一管理外部中断的总线
    查看中文参考手册的框图,可得知,EXTI提供边缘检测,可以将GPIO的引脚变化转换为一个中断或事件信号。
  • 由中断线可以看到,多个GPIO分组连接到16条中断线上,即每个中断线都只能同时与一个GPIO口绑定,而且哪些口能和那个中断线绑定是硬件规定的
  • 还有另外独立的几根中断线,专门给到RTC,以太网等功能

注意

  • 如果需要设置多个中断同时进行时,需要使用尾号不相同的,才能不让中断线重复被绑定,如果实在需要使用尾号相同的,可以使用轮询而不是中断(效率很低)
    EXTI_GetITStatus(EXTI_Line0) != RESET

NVIC管理外部中断

意义

  • 一个统一管理中断的系统,决定中断何时可以抢夺CPU时间片

中断任务组

意义

  • 提供两种优先级方案的具体策略

具体

  • 当我们编程STM32项目时,应该提前考虑:一个统一的优先级组策略,因为stm32只有4位中断优先级,我们要提前规定,几位作为抢占,几位作为子优先级,并且全局配置。第0组全是子优先级,第4组全是抢占优先级。

理解

  • 抢占优先级不同时,即当等级高的中断来临时会抢占低优先级的CPU时间片。子优先级即抢占优先级相同时,当两个中断同时到来时,CPU会根据子优先级决定先后执行,与抢占不同,高优先级不会抢占低优先级时间片。

疑问

  • 如何选择使用抢占优先级还是子优先级?
    根据逻辑选择,抢占式的中断应该是需要立即响应的中断,例如刹车。而子优先级的中断应该是需要顺序响应的中断,例如接收消息和处理消息

按键消抖

如果中断是根据上升下降沿处理,那么如果我是使用查询法而不是中断,就不会抖动吗?
根据逻辑,可能会闪,逻辑正确会完成,但是这种查询法往往是长时间占用CPU,不常用.

课堂内容
systick需要使用寄存器开发,因为它需要保证时间的精准

系统时钟计数器是24位,最大值是2^24,在168MHz频率下,最大可以定时99.86ms,可以通过设置频率增加最大定时或者使用计数器累加的方法实现长定时

系统时钟SysTick Timer

  • 内核提供的定时器

[内核手册] 24位计数器,有重装机制,只能向下计数
[core_m4.h]查看它的配置函数,看看他是怎么配置的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
__STATIC_INLINE uint32_t SysTick_Config(uint32_t ticks)
{
//判断,重装值-1不能大于它的最大值
if ((ticks - 1UL) > SysTick_LOAD_RELOAD_Msk)
{
return (1UL); /* Reload value impossible */
}
//设置重装值
SysTick->LOAD = (uint32_t)(ticks - 1UL); /* set reload register */
//设置优先级,这里设置为最低
NVIC_SetPriority (SysTick_IRQn, (1UL << __NVIC_PRIO_BITS) - 1UL); /* set Priority for Systick Interrupt */
//计数值清零
SysTick->VAL = 0UL; /* Load the SysTick Counter Value */
//时钟源选择AHB(0x100),使能中断,使能计时器
SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk |
SysTick_CTRL_TICKINT_Msk |
SysTick_CTRL_ENABLE_Msk; /* Enable SysTick IRQ and SysTick Timer */
return (0UL); /* Function successful */
}

SysTick_Config(SystemCoreClock / 1000);//一秒的计数次数除以一千就是1毫秒
重写void SysTick_Handler(void)即可配置系统定时器的中断

通用定时器 General purpose timer

PWN

意义

  • 一种通过定时器快速切换电平的方案

为什么需要PWM?
我们可以通过改变一个周期内1的占空比可以模拟不同的电压
比如LED灯的亮度和平均电压有关。我们使用的3.3V是电平的幅度达到3.3
当我们改变高低电平的(空占比)比例时,就可以将平均的电压降低,达到和降低电平幅度一样的效果
但是通过软件(代码)修改电平的速度太慢了,而且不可靠,所以我们需要借助定时器来稳定修改电平的变换

重要参数有:

  • Prescaler(决定定时器频率,即每秒定时器计数多少次),Period(计数区间,即定时器最大值),Pulse(比较值,决定空占比)

基础操作

  • 首先我们需要考虑PWM输出频率,也就是每秒输出的 PWM周期数,这个值应该根据硬件特性来选择,比如硬件是LED的常用频率是100 Hz 到 1 kHz ,频率过低会明显闪烁,过高肉眼不好观察。电机的常用频率是1 kHz 到 20 kHz,过低会产生明显噪声,过高损增大功耗。

  • 其次我们可以将Period设置为99,也就是每个PWM周期的计时器最大值,让计数值为0~99,这样可以让Pulse更人性化,可以看成百分比。

  • 根据以上两个值的设定,我们可以得到需要的定时器频率为:PWM输出频率*(Period+1),得到分频值为输入时钟频率168Mhz/定时器频率 -1

USART Universal Synchronous/Asynchronous Receiver/Transmitter

意义

  • 一种方便的传输协议

理解
物理层电平标准 :TTL,RS232,USB ,决定了电压范围及特性
应用层数据帧格式:起始位,数据位,校验位,结束位

首先,明确这里是根据USB座和MCU通信,USB协议需要转成串口协议,于是需要CH340G芯片,芯片转换后的口和复用后的GPIO口相连
查看原理图,USB座给出D-和D+,在CH340G中转换成RXD,TXD,这两个脚可以通过跳线帽和复用GPIO口相连,原理图上显示为USART1_TX和USART_RX
搜索这两个复用GPIO口,发现是PA9和PA10

关键
数据的发送类似消息队列,发送过来的信息由MCU将其存放在一个缓冲区中,由C代码处理。而需要发送出去的消息由C代码放到缓冲区,由MCU自动发送。整个过程可以通过查询寄存器状态来实现逻辑
STM32为串口注册了专用的中断服务函数USARTx_IRQHandler
STM32会检测RXNE是否置位来触发中断,表示缓冲区有内容,此时可以读取缓冲区的消息。发送时,通过检测USART_FLAG_TXE是否置位来判断缓冲区是否空,如果置位表示缓冲区空,即消息已经被MCU发送出去了

接收数据的方案

  • 最简单的方法,可以一个字节一个字节(8位数据位设置下)地读取,也就是说,一个字节触发一次中断,逐个读取即可。对于多字节数据,C希望读取完整的数据,可以定义结束符,然后每个字节判断是否读到结束符。
  • 对于多字节数据,多使用一个定时器,根据连续的数据传输的时间间隔和非连续数据传输时间间隔的差距,可以判断是否结束
  • 利用芯片支持的串口空闲中断,当检测到串口空闲时相应的寄存器置位,判断为收数据结束,可以添加自定义逻辑。

重定向

1
2
3
4
5
6
7
8
9
10
11
12
; Reset handler
Reset_Handler PROC
EXPORT Reset_Handler [WEAK]
IMPORT SystemInit
IMPORT __main

LDR R0, =SystemInit
BLX R0
LDR R0, =__main
BX R0
ENDP

自举配置BOOT

意义

  • 提供了三种STM32的启动地址,从哪里执行程序

根据ST参考手册可以知道,boot有三种启动地址FLASH、SRAM、system memory
system memory地址的代码厂家编写好了,用来将代码下载到flash

时钟树

意义

  • 列出了所有时钟的频率和他们的联系
    库函数编程时,修改系统时钟频率,可以在system_stm32…文件里面修改
    寄存器编程时,可以修改RCC_CFGR时钟配置寄存器
  • 可以通过RCC_PLLConfig等函数修改PLL获得不同的时钟频率

一般情况下,我们选择HSE作为PLL时钟,在我的开发版上HSE由8Mhz的晶振提供,经过PLL后得到了168Mhz的PLLCLK,再将168MHz的时钟分频得到AHB和APBx。希望调整PLL时钟,比较方便的方法是修改复位时调用的SetSysClock()函数中的分频和倍频因子

另外,对于挂载在APB总线的计时器来说,如果APB的预分频大于1,时钟需要翻倍

启动文件

  1. 定义堆栈段
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Stack_Size      EQU     0x00000400

AREA STACK, NOINIT, READWRITE, ALIGN=3
Stack_Mem SPACE Stack_Size
__initial_sp

; <h> Heap Configuration
; <o> Heap Size (in Bytes) <0x0-0xFFFFFFFF:8>
; </h>

Heap_Size EQU 0x00000200

AREA HEAP, NOINIT, READWRITE, ALIGN=3
__heap_base
Heap_Mem SPACE Heap_Size
__heap_limit

PRESERVE8
THUMB

由汇编代码,可以看到堆栈的大小
由链接脚本,可以得知生长方向,栈通常向下,堆通常向上,当它们相遇时,会导致溢出。

  1. 定义向量表
    [ST中文参考手册中断和事件] 向量表在flash中,从0地址开始每4字节存储一个中断服务函数的入口地址,如果你重定义一个中断服务函数,那么它的地址就会被链接器加载到这个向量表

  2. 开始执行复位中断服务函数
    服务函数中包括时钟初始化

看门狗

独立看门狗IWDG

  • LSI时钟源,独立可靠,需要考虑分频值和重装值来配置看门狗主要工作参数
  • 一旦启动无法关闭,必须持续喂狗(定期写入关键值),否则系统复位 怎么做到定期喂狗?定时器吗?,那还有什么意义,定时器不是主程序崩了他也能一直工作吗?
    定时器喂狗和主循环喂狗是两种方案,根据需求选择,如果在定时器中喂狗,那么无法检测主程序的崩溃,所以一般简单的需求在主程序中喂狗。

窗口看门狗WWDG

  • 系统时钟,更灵活,需要考虑分频值、

RTC Real-Time Clock

  1. 选择时钟源:LSE(最常用)、HSE、LSI 看时钟树,英文全称,独立于系统时钟之外?
    LSI = 32 KHz LSE = 32.768KHz 经过异步和同步分频得到1Hz,也就是一秒跳一次,这样符合时间的更新习惯,这里的时钟独立于系统时钟之外

  2. 初始化,分频:Backup Domain Reset,设置合适的分频值。 怎么考虑分频值?
    异步分频和同步分频都要配置,一般情况下,直接将异步分频器拉满127,降低功耗,然后精确调整同步分频器的值来精确调整

1
2
RTC_InitStructure.RTC_AsynchPrediv = 127;
RTC_InitStructure.RTC_SynchPrediv = (32768 / 128) - 1; // 1Hz
  1. 解锁,使用BCD格式设置时间和日期 怎么保证时间和日期设置时是准的?
    在现有情况下,无法保证程序运行的时间,因此无法保证准确
    使用NTP 或 GPS 时间同步可以做到精准同步真实时间

  2. 可以设置唤醒、中断和闹钟(可选) 这三个不是同一个东西吗?
    唤醒和闹钟通过中断执行任务。
    唤醒即将MCU从低功耗模式唤醒,闹钟即RTC定时发生中断。

  3. 配置低功耗模式运行模式(可选)

  4. 配置备用寄存器
    备用寄存器一般配置为一个标志位,标志时间是否已经载入过,即置位时可以检查是否需要重新载入特定的时间值

  5. 解决不定长数据包

ADC Analog-to-Digital Converter

  • 我们为什么要使用ADC?
    由于很多传感器,比如光敏传感器,电量等信号是通过电压值这种模拟信号来表示的,计算机只能处理数字信号,因此需要使用一个能将模拟信号转换为数字信号的东西,ADC。

步骤
采样 、 量化(转化) 、 编码

  • 需要考虑的几个参数:分辨率、采样时间、对齐模式、时钟、
  1. ADC时钟的选择:
    过高的ADC时钟频率会导致采样不准确,主要是因为采样时间太短,ADC内部的采样电容还来不及完全充放电。频率越高,电路中的噪声效应也会越明显,这可能会干扰采样结果。
    如果时钟频率太高,ADC的采样和转换窗口期可能不够,导致转换阶段还没完成就开始下一次采样,可能导致数据错误或不稳定。
    如果时钟频率过低,采样速度会变慢,导致无法快速响应输入信号的变化,特别是在信号快速变化的场景中(例如电压监测时,电压快速波动)。
    STM32的ADC在12位分辨率下,转换时间通常是固定的12个ADC时钟周期,因此时钟频率的选择会直接影响到总体的转换时间。
  2. 采样时间:
    采样时间可以选择为多个ADC时钟周期,例如3个、15个或更多时钟周期。采样时间越长,内部采样电容有更多时间充电,确保电压值稳定。
    采样时间的选择和输入信号的源阻抗有关。如果信号源阻抗较高,则需要选择较长的采样时间,以便让电容完全充满。
    一般来说,对于低阻抗源,可以选择较短的采样时间,而对于高阻抗源,需要增加采样时间。
  3. 分辨率:
    分辨率决定了ADC转换过程中量化后的最小可检测变化单位。比如12位的ADC可以将输入电压量化为2^12 = 4096个不同的离散值,越高的分辨率能区分更小的电压变化。
    分辨率高意味着更精确的结果,但也可能增加采样和转换的时间(根据不同的分辨率选择,ADC可能调整转换时间)
  • 不同场景需要考虑的功能:工作模式、触发源、使用DMA还是中断处理、校准
    工作模式:多个ADC交错采集,多通道扫描,是否连续工作

SPI Serial Peripheral Interface

意义

  • 提供了一种同步收发机制,一般用于近距离传输,一个板子上的设备

在串口通信中,我们采用异步收发,需要约定波特率,也就是双方规定一个位的时间是多久,在SPI中,我们使用同一条时间线来解决。一方在上升或者下降沿发消息,一方就在另一个沿接收消息。收发双方是没有确认的,类似UDP无连接,发送者不会管接收者是否接收

不同场景需要考虑:

  • 极性和相位,极性即空闲时钟是高电平还是低电平,相位即是上升沿发,下降沿收还是相反。主机(可编程)需要根据从机(不可编程,出厂已决定)的极性和相位来配置

存储器

非易失存储器

RAM

易失存储器

ROM FLASH 各种盘

I2C

  • 一种非常节省总线的通信方式

电气特性:
设计为两条线都是上拉电阻,也就是逻辑0比逻辑1更优先。这种设计可以达到总线占用的效果

协议特性:
基本:半双工通信,由主机发起开始和结束信号,从机根据信号开始,发送/读取,结束。其中,每个数据帧都伴随着接收机的一次应答。
高级:有些情况下,主机可能需要携带数据,且作为接收机,因此需要先作为发送机,再作为接收机,不断转换

仲裁(占用总线)、结束信号(释放总线)、数据有效、应答、设备地址

仲裁机制(将总线看作信号量)

  • 可以达到总线占用的效果
    主机起始条件:SCL为默认高,SDA也为默认高,判断为总线空闲(会监控一段时间,防止SDA正在拉低途中被判断为高,防止SCL刚好到达高电平被判断为高)
    判断空闲后,拉低SDA,占用总线,此时总线状态为忙碌

结束信号

  • 可以释放总线
    主机希望结束,将SCL控制为高电平,即断开时钟发生器,然后将SDA也设置为高阻态

数据有效

  • I2C约定SCL高电平时SDA上的数据有效,发送机应该在这时候给出一个数据位,接收机应该在这时候采样一个数据位。
  • 每个时钟周期传输一位数据

应答

  • I2C约定每传输完一个数据帧(一般为一个字节)也就是第9个时钟周期,发送机对SDA高阻态,SDA交由接收方控制,此时接收方应该给出应答(低电平)或者非应答(高电平)到SDA。

FreeRTOS

  • 一个具有实时性、基于优先级的抢占式任务调度、多任务管理、灵活的中断和任务间通信机制,适用于资源受限的嵌入式系统。

任务调度器

  • 一个管理和分配CPU时间片的机制

过程:

  • 任务状态管理
  • 优先级
  • 抢占式调度策略
  • 时间片轮转策略
  • 调度点
  • 系统滴答时钟
  • 挂起调度器
  • 优先级反转与继承

中断管理

我们使用RTOS时,应该设置configMAX_SYSCALL_INTERRUPT_PRIORITY为较高优先级,其他硬件中断的优先级比这个优先级低,为什么?
由于FreeRTOS调度器是通过SysTick中断来运行的,一般需要确保调度器的优先级最高,否则中断将会抢占CPU时间片导致调度器停摆,中断中调用的Free RTOS的API当然也是无法使用

我们什么情况下需要使用Free RTOS的中断安全API?
试想,如果在处理中断时,一个优先级更高的任务进入就绪态,将会打断中断,进行上下文切换。使用中断安全API可以让中断安全执行完毕后再进行上下文切换。但是如果是刹车型不应该被延后执行的任务,应该防止中断安全API或者直接将优先级设置高于configMAX_SYSCALL_INTERRUPT_PRIORITY

任务的创建

1
2
3
4
5
6
xTaskCreate((TaskFunction_t )app_task1,  		/* 任务入口函数 */
(const char* )"app_task1", /* 任务名字 */
(uint16_t )512, /* 任务栈大小 */
(void* )NULL, /* 任务入口函数参数 */
(UBaseType_t )4, /* 任务的优先级 */
(TaskHandle_t* )&app_task1_handle); /* 任务控制块指针 */

其中,最后一个参数看作线程的句柄,给入一个TaskHandle_t*,creat函数将这个指针指向任务,从此内核可以通过这个句柄操作这个任务
我们需要执行任务,需要开启任务调度器,vTaskStartScheduler(); 一旦开启,CPU将会听从调度器,将不会运行主函数代码

在free RTOS中,中断是软件配置的,因此优先级不局限于NVIC的4位寄存器。可以设置很多级优先级,设置最大值宏为configMAX_PRIORITIES

栈的大小如何选择?
栈空间有一个可调整的最小值宏。栈的大小先写大一些。业务逻辑完成后缩减。在生成文件中查看程序占用栈大小,看情况进行缩减

free RTOS会有哪些常见的调度点

1、 任务延时和阻塞
2、 任务的挂起和恢复,创建和删除
3、 中断触发:在free RTOS中,中断比普通任务拥有更高的优先级,因为中断是由硬件触发的。

绝对延时 vTaskDelayUntil( &xLastWakeTime, xFrequency );

相比于相对延时,绝对延时可以根据实际时间动态调整延时时间,以达到精准绝对延时,如果发现已经超时,将会立即返回,并且将时间戳更新为正常的延时时间点。

具体操作vTaskDelayUntil会将xLastWakeTime的值更新为下一个跳出这个函数的绝对时间点

信号量、互斥锁、互斥型信号量如何选择

信号量 适用任务间同步

  • 当我们一个任务控制另一个任务时,可以设置一个信号量,上级任务V操作,下级任务P操作
  • 相比于互斥锁,信号量可以累积。

互斥锁

  • 用于保护共享资源,不允许累积,可能会导致优先级反转问题

互斥型信号量

  • 提供优先级继承机制,可以临时提高优先级,有效避免优先级反转问题的互斥锁

事件标志组

taskENTER_CRITICAL() 和 taskEXIT_CRITICAL()

意义:

  • 提供能够和中断比较优先级的方案
    可以设置安全领域,进入临界区,连中断(临界优先级以下的)都能屏蔽。

底层:
FreeRTOS可以通过操作BASEPRI来频闭某一个区间内的中断

注意事项:
两个函数采用嵌套结构,严格一对一
不能在中断中启用临界区
临界区尽量简短,且不能使用FreeRTOS API函数

1. 线程的堆栈和堆空间

在 FreeRTOS 中,任务(Task) 是有自己独立的栈空间的,但在传统的操作系统(如 Linux)中,线程(Thread) 共享进程的堆(Heap)空间,但每个线程有自己独立的栈(Stack)空间。

  • 线程的栈空间:每个线程在创建时都会分配自己的栈空间,用于保存局部变量、函数调用链和返回地址等。
  • 线程的堆空间:所有线程共享同一进程的堆空间,因此一个线程在堆上分配的内存可以被其他线程访问和修改。

2. 任务饿死和优先级反转

  • 任务饿死(Task Starvation):当一个高优先级的任务频繁占用 CPU 时,低优先级的任务可能一直得不到执行,导致“饿死”。这种情况下,低优先级任务可能永远无法得到 CPU 时间片。

  • 优先级反转(Priority Inversion):在优先级调度的系统中,低优先级任务占有某个资源,而高优先级任务因为等待该资源被阻塞,同时另一个中等优先级的任务抢占了 CPU,导致高优先级任务的执行被延迟,这就是优先级反转。

    举例:如果任务 A(高优先级)等待一个被任务 C(低优先级)占用的资源,而任务 B(中优先级)没有占用资源,但抢占了 CPU 时间片,任务 A 就会被延迟,尽管任务 A 优先级最高。

    互斥锁优先级继承:为了解决优先级反转问题,FreeRTOS 提供了 优先级继承机制。当一个低优先级的任务(例如任务 C)持有某个资源(例如互斥锁)时,且高优先级任务(例如任务 A)正在等待这个资源,FreeRTOS 会临时提升低优先级任务 C 的优先级到与任务 A 一样高,直到任务 C 释放资源。这样可以确保高优先级任务尽快得到资源,避免中优先级任务 B 的干扰。

3. FreeRTOS 为什么不使用通用定时器进行延时?

FreeRTOS 的 vTaskDelay() 函数是基于系统的 滴答时钟(Tick Timer) 来实现的,而不是通用定时器。滴答时钟通常在 1 毫秒或更长的时间间隔触发一次。因为滴答时钟是操作系统调度的基础,所有任务延时和时间片轮转都依赖它。

  • vTaskDelay() 不能实现微秒级延时:由于 vTaskDelay() 是基于滴答时钟的,而滴答时钟的频率较低(通常是 1 毫秒或者更长),因此它无法实现微秒级别的延时。它更适用于任务的毫秒级别延时。

  • 微秒级延时:如果需要微秒级的延时,通常需要依赖硬件定时器或直接在裸机程序中使用精确的延时函数(例如硬件定时器或 NOP 指令的循环)。

4. 解耦合与面向对象

  • 解耦合:解耦合是一种设计理念,旨在减少不同模块之间的依赖性,使得各模块可以独立修改、测试或复用。它强调通过清晰的接口和模块分离来减少耦合。虽然解耦合与面向对象编程(OOP)有一定的相似之处,但它并不限于 OOP。

    • 在面向对象编程中,解耦合常通过 接口继承多态 实现。
    • 在 C 语言或嵌入式编程中,可以通过模块化设计、函数指针和接口封装等方式实现解耦合。

5. ISR 安全函数

  • ISR 安全函数:在 FreeRTOS 中,有一类特殊的函数专门用于在中断服务程序(ISR)中调用,这些函数的名称通常以 FromISR 结尾,例如 xQueueSendFromISR()xSemaphoreGiveFromISR()。这些函数是为中断上下文设计的,可以安全地在中断处理函数中调用。

    原因

    • 在中断上下文中,不能使用可能导致任务切换或引起阻塞的 FreeRTOS API。因此,FreeRTOS 提供了一些专门用于 ISR 的函数,它们确保不会发生上下文切换,同时使用中断安全的机制更新队列、信号量等资源。
  • 与裸机中断的区别:在裸机编程中,中断通常是直接与硬件中断号绑定的,比如直接使用 NVIC 来处理中断。在 FreeRTOS 中,为了保持实时系统的任务调度和资源管理的可控性,推荐使用 FreeRTOS 提供的 ISR 安全函数而不是直接操作硬件中断。

6. 软件定时器

  • 软件定时器:FreeRTOS 中的 软件定时器 是一个不依赖于硬件的定时器,它在任务上下文中运行。与硬件定时器不同,软件定时器是在 FreeRTOS 的调度器中通过滴答计时实现的,能够在特定时间到达时执行一个回调函数。

    特点

    • 软件定时器是在 任务上下文中执行的,也就是说当一个软件定时器的时间到达时,FreeRTOS 调度器会将其作为任务的一部分来执行。
    • 可以用于实现 周期性任务一次性延时任务
  • 实现方式

    • 创建一个软件定时器时,你需要指定定时器的回调函数和定时器的周期时间。当定时器到期时,回调函数会被调用。

    示例代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    // 回调函数
    void TimerCallback(TimerHandle_t xTimer)
    {
    // 定时器到期后的处理逻辑
    }

    void setupTimer()
    {
    // 创建一个周期为1000ms的定时器
    TimerHandle_t xTimer = xTimerCreate("Timer", pdMS_TO_TICKS(1000), pdTRUE, (void*)0, TimerCallback);

    if(xTimer != NULL) {
    // 启动定时器
    xTimerStart(xTimer, 0);
    }
    }

    在这个例子中,定时器每隔 1000 毫秒调用一次 TimerCallback 函数。

7. 总结与注意事项

  • 线程与堆栈:线程有独立的栈空间,但共享进程的堆空间。FreeRTOS 中每个任务有独立的栈。
  • 任务饿死和优先级反转:需要合理设计任务优先级,避免任务饿死,并使用互斥锁的优先级继承机制解决优先级反转问题。
  • 延时vTaskDelay() 只能提供毫秒级延时,不能实现微秒级延时。如果需要精确的微秒延时,通常需要使用硬件定时器。
  • 解耦合:解耦合是面向对象编程的一个原则,但在非面向对象的系统中也可以实现,特别是在嵌入式系统中通过模块化设计实现解耦。
  • ISR 安全函数:在中断上下文中必须使用 FreeRTOS 提供的 FromISR 函数,这些函数是专门为中断设计的,能够避免中断处理中的问题。
  • 软件定时器:用于定期执行任务,允许你在任务上下文中执行基于时间的回调函数,不需要依赖硬件定时器。