ARM:Cortex-M3内核的System系统时钟详解、基于Keil5Debug的性能测试及实际使用、函数重构(基于STMF1系列单片机和C语言的系统延时)
目录
3.2.1 CTRL (控制与状态寄存器) - 地址: 0xE000E010
3.2.2 LOAD (重装载值寄存器) - 地址: 0xE000E014
3.2.3 VAL (当前值寄存器) - 地址: 0xE000E018
3.2.4 CALIB (校准值寄存器) - 地址: 0xE000E01C
一、前言
本文前半段适用于科普;
本文后半段仅适用于电子、计算机等有计算机、嵌入式处理器相关知识基础的学生;
本文代码段适用于任何ARM公司Cortex-M3内核的处理器,且逻辑适用于所有内核;
本文仅作学习参考;
我在以下文章中发现了STM32F1系列单片机在SysTick(系统时钟)频繁开断即写入的情况下出现了系统谬误的问题。(最近又是生病又是猛玩鸭科夫,拖到现在....)
同时我意识到这种最常见最普遍的系统延时是存在另一个问题的——即忽略了除精确延时代码以外的其他功能代码的资源消耗。
即:延时代码固定为1us,但其他如开断系统时钟、寄存器重写入等代码段也消耗时间,那如何确保整个延时函数的精确性呢?
本文章主要内容:
(1):什么是内核、处理器内核;
(2):单片机Systick详解、延时函数性能测试、及延时函数重构
二、处理器内核
2.1 什么是内核
内核(我快不认识这两个字了...)指的是任何功能集合的最基础、最核心、最本质的部分。例如我们可以说我们人类的内核是大脑和脊椎等中枢神经系统,汽车的内核是发动机或者发电机。
而在不同前缀下,内核也是变化的。如我们可以说我的华硕电脑的内核是Windows11系统,但如果说我的华硕电脑系统的内核则是12th Gen Intel(R) Core(TM) i7-12700H。
在我们上述的电脑内核中,Windows系统和CPU就是电子、计算机领域的两个核心内核概念——系统内核和处理器内核。
系统内核是硬件和外部延申部件之间的桥梁,常见的操作系统有:Windows、IOS、鸿蒙、安卓、Linux、FreeRTOS、Unix、QNX、macOS等。操作系统没有好和坏,只有合适。
处理器内核一般指的是算力核心,常见的处理器内核有:CPU、MCU、GPU等。他们都负责自身控制范围内的基本算力,是系统的大脑。同样,处理器内核没有好坏,只有合适。
| 公司/组织 | 内核架构 | 商业模式 | 主要应用领域 |
| ARM | ARM | 知识产权授权 | 绝对主导:手机、物联网、嵌入式系统 |
| Intel/AMD | x86/x86-64 | 销售芯片 | 主导:个人电脑、服务器、高性能计算 |
| RISC-V International | RISC-V | 开源、免费 | 快速增长:物联网、AI加速、新兴市场、定制芯片 |
| MIPS Technologies | MIPS | 知识产权授权 | 传统领域:网络设备、嵌入式系统(影响力下降) |
| Synopsys | ARC | 知识产权授权 | 特定市场:存储、汽车、需要高度定制的SoC |
| Microchip | PIC, AVR | 销售芯片 | 经典8/16位: 教育、简单控制、低成本设备 |
2.2 Cortex-M3
本文的STM32F1系列单片机则属于嵌入式处理器内核中的MCU,也就是微控制器单元。
MCU通常集成了更核心的处理器核心(CPU)、储存器(RAM/ROM)、输出输入接口(I/O)、定时器及UART等。
而本文的Cortex-M3内核就是STM32F1系列单片机的处理器核心。
当然,Cortex-M系列内核并非STM32F1系列单片机的专属,此内核在单片机(MCU)领域几乎是垄断性的。
| 内核类型 | 主要特点 | 代表STM32系列 | 典型应用 |
| Cortex-M0/M0+ | 入门级、低成本、低功耗 | F0, L0, G0 | 简单控制、消费电子 |
| Cortex-M3 | 性能平衡、经典 | F1 | 工业控制、通用市场 |
| Cortex-M4 | 带DSP和FPU、性能强劲 | F4, L4, G4 | 电机驱动、音频处理、数字电源 |
| Cortex-M7 | 高性能、高主频、双精度FPU | F7, H7 | 高端HMI、复杂计算、网关 |
| Cortex-M33 | 带TrustZone安全、高能效 | L5, U5 | 物联网安全设备、可穿戴设备 |
| Cortex-M55 | 强大的AI/ML加速能力 | (未来产品) | 人工智能物联网 |
ARM公司和Intel/AMD公司几乎占据了整个芯片行业的市场,但也有后来者在尝试追赶。如RISC-V和龙芯中科。期待他们有能撼动巨头的能力。
三、System系统延时
本文编程语言: C语言
本文集成开发环境: Keil uVision5
本文硬件基础: Stm32F10X
本文编码规则: UTF-8
本文DEBUG模式: ST-Link Debugger
3.1 常见系统时钟软件延时
以下是常用的SysTick系统延时函数,其基本函数为void Delay_us(uint32_t xus);
功能段为Cortex-M3内置架构,通过操作SysTick寄存器实现微秒级定时功能。
而void Delay_ms(uint32_t xms);和void Delay_s(uint32_t xms);函数只是对Delay_us(1000);的循环嵌套。
#include "stm32f10x.h"
/**
* @brief 微秒级延时
* @param xus 延时时长,范围:0~233015
* @retval 无
*/
void Delay_us(uint32_t xus)
{
SysTick->LOAD = 72 * xus; //设置定时器重装值
SysTick->VAL = 0x00; //清空当前计数值
SysTick->CTRL = 0x00000005; //设置时钟源为HCLK,启动定时器
while(!(SysTick->CTRL & 0x00010000)); //等待计数到0
SysTick->CTRL = 0x00000004; //关闭定时器
}
/**
* @brief 毫秒级延时
* @param xms 延时时长,范围:0~4294967295
* @retval 无
*/
void Delay_ms(uint32_t xms)
{
while(xms--)
{
Delay_us(1000);
}
}
/**
* @brief 秒级延时
* @param xs 延时时长,范围:0~4294967295
* @retval 无
*/
void Delay_s(uint32_t xs)
{
while(xs--)
{
Delay_ms(1000);
}
}
3.2 System寄存器详解
以下进行System寄存器详解
3.2.1 CTRL (控制与状态寄存器) - 地址: 0xE000E010
| 位 | 名称 | 数值 | 功能描述 |
|---|---|---|---|
| 16 | COUNTFLAG | 0x00010000 | 计数结束标志位(只读)。当计数器从1减到0时,该位被硬件置1,读取后自动清0 |
| 2 | CLKSOURCE | 0x00000004 | 时钟源选择位: 0 = 使用外部参考时钟 (HCLK/8) 1 = 使用处理器时钟 (HCLK) |
| 1 | TICKINT | 0x00000002 | 中断使能位: 0 = 计数器减到0时不产生中断 1 = 计数器减到0时产生SysTick异常 |
| 0 | ENABLE | 0x00000001 |
定时器使能位: |
3.2.2 LOAD (重装载值寄存器) - 地址: 0xE000E014
| 位范围 | 名称 | 数值范围 | 功能描述 |
|---|---|---|---|
| 23:0 | RELOAD | 0x00000000 - 0x00FFFFFF | 24位重装载值。当计数器减到0时,自动将此值加载到VAL寄存器 |
为24位寄存器,故最大值为16777215d。
3.2.3 VAL (当前值寄存器) - 地址: 0xE000E018
| 位范围 | 名称 | 数值范围 | 功能描述 |
|---|---|---|---|
| 23:0 | CURRENT | 0x00000000 - 0x00FFFFFF | 24位当前计数器值。写入任何值都会清空计数器 |
为24位寄存器,故最大值为16777215d。
3.2.4 CALIB (校准值寄存器) - 地址: 0xE000E01C
| 位 | 名称 | 数值 | 功能描述 |
|---|---|---|---|
| 31 | NOREF | 0x80000000 | 1 = 没有外部参考时钟可用 |
| 30 | SKEW | 0x40000000 | 1 = 校准值不是精确的10ms |
| 23:0 | TENMS | 0x00FFFFFF | 10ms校准值(芯片厂商提供) |
3.3 常见系统时钟软件延时解释
SysTick->LOAD = 72 * xus; //设置定时器重装值
SysTick->VAL = 0x00; //清空当前计数值
SysTick->CTRL = 0x00000005; //设置时钟源为HCLK,启动定时器
while(!(SysTick->CTRL & 0x00010000)); //等待计数到0
SysTick->CTRL = 0x00000004; //关闭定时器
由于Cortex-M3内置了寄存器操作指针集,所以我们无需通过寄存器地址操作。
通过与3.2节对比,很容易看出来,整个代码段的功能为xus延时。
SysTick->LOAD=72*xus;
//通过3.2.2小节描述。由于系统频率为72MHz,所以重装值应设为72M/1us*x(us)。(实际上应设为72M/1us*x-1(us)),当值减到0时自动装入VAL寄存器,故使用中不需要每次清空计数值。
SysTick->VAL=0x00;
//通过3.2.3小节描述。向VAL寄存器写入任何值都会清空当前计数值
SysTick->CTRL=0x00000005;
//通过3.2.1小节的描述,我们能看出实际上我们通常写入的只有低3位。
//这里我们设置了2位为1,1位为0,0位为1。即:0b101。即:0x05。
//作用为:时钟源为HCLK,不产生中断,开启时钟
While(!(SysTick->CTRL & 0x0010000));
//通过3.2.1小节的描述,我们能看出实际上我们通常读取的只有16位。
//这里我们读取16位,即0b1 0000 0000 0000 0000,即0x10000。
//作用为:读取COUNTFLAG标志位,当计数值清零后,此标志位置1。故等待标志位即可实现延时。由于读取后自动清零,故不需要每次清空标志位。
SysTick->CTRL=0x00000004;
//这里我们设置了2位为1,1位为0,0位为0。即:0b100。即:0x04。
//作用为:时钟源为HCLK,不产生中断,关闭时钟
四、 常见System系统时钟软件延时的性能测试暨测试教学
通过以上小节的描述,我们清楚的理解了系统时钟软件延时的逻辑。此时相信大家也会有和我一样的疑惑——常见的软件延时函数的性能到底如何。
为了探究这个问题,我采用Keil uvision5的ST-Link Debug模式来测试。
若您想复现本实验,请下载Keil5集成编译环境软件、购买基于Cortex-M3内核的MCU、配置ST-Link调试器驱动、购买ST-Link调试器并连接、配置好调试环境。
4.1 环境配置
基础的环境配置在此不赘述,若需要请看其他大佬的文章:
4.1.1 配置外部晶振
请检查您的单片机最小系统的外部晶振,通常为8Mhz。
点击魔术棒,找到Target。
![]()

4.1.2 配置输出模式*
找到Output,勾选Debug information

4.1.3 配置Debug模式
找到Debug,勾选Use ST-Link Debugger

点击右侧Settings
检查Debug窗口下的硬件连接方式,通常设置时钟频率为4Mhz及以下
勾选Reset after Connect,连接后自动复位

点击Trace窗口,检查系统核心频率
本实验使用的STM32F103C8T6单片机核心频率为72MHz(外部时钟8MHz的9倍频)

4.2 Debug调试测试教程
![]()
通过ST-Link连接好硬件后,点击上图的d,进入调试模式。

本实验所用调试功能较少。
下图圆圈为向代码行添加断点。


本实验用到如下图的运行至断点,断点处代码行不会被执行。

如下图,左下角SEC为程序运行总时间。

4.3 性能测试
由于我们无法知道系统的启动时间(单片机系统启动时间取决于多种因素,且为随机值)。
且为了准确性,我们采用时间差值来确定一个代码段所消耗的时间。
如:运行至断点时间为T1,运行至下一个断点时间为T2,则两个断点中间的代码段运行时间Td为T2-T1。
在此不展示测试过程,直接给出测试结果。且直接给出结论。
(1)首先,我们进行1次循环的基本函数测试,也就是1us延时。
| 代码段 | 时间点(s) | 差(s) | 耗时 | 单位 |
| 0.163017720 | ||||
| delay_us(1) | 0.163019350 | 0.00000163 | 1.630000 | us |
| delay_us(1) | 0.163020960 | 0.00000161 | 1.610000 | us |
| delay_us(1) | 0.163022570 | 0.00000161 | 1.610000 | us |
| delay_us(1) | 0.163024210 | 0.00000164 | 1.640000 | us |
我们会发现,实际耗时为1.63us,大大超过了所需求的1us。
且每次测试有区别,这是由于运行速度的不确定性,随机值在理想范围内。
至于超出的绝大多数部分,则是由于多余的代码行,详见(2)测试。
(2)基本函数逐行测试
| 代码段 | 时间点(s) | 差(s) | 耗时 | 单位 |
| 0.134292140 | ||||
| SysTick->LOAD = 72 * 1; | 0.134292210 | 0.00000007 | 0.070000 | us |
| SysTick->CTRL = 0x00000005; | 0.134292260 | 0.00000005 | 0.050000 | us |
| SysTick->VAL = 0x00; | 0.134292310 | 0.00000005 | 0.050000 | us |
| while(!(SysTick->CTRL & 0x00010000)); | 0.134293320 | 0.00000101 | 1.010000 | us |
| SysTick->CTRL = 0x00000004; | 0.134293390 | 0.00000007 | 0.070000 | us |
| 总计 | 1.250000 | us |
那有人就说了,欸你这不对啊,怎么单步运行加起来是1.25us,但基本函数是1.62us左右呢?
这是由于实际上单步运行是不准的,我们中断了程序的运行,但定时器寄存器的操作已经开始,我们无法中断时钟。所以整体运行时间会小于连续运行这段代码。
这里仅仅只是在展示每一行代码都是需要时间消耗的。
而这段代码连续运行的时间约为1.47us。与(1)中1.6us的差值来源于函数的调用消耗。
我们将while(!(SysTick->CTRL & 0x00010000));称之为时间函数,其运行时间取决于重装值且较为精准。
将此代码段其他函数称之为误差函数,其运行时间基本固定。
(3)1ms测试
| 代码段 | 时间点(s) | 差(s) | 耗时 | 单位 |
| 0.163018940 | ||||
| delay_ms(1) | 0.164020060 | 0.00100112 | 1.001120 | ms |
| delay_ms(1) | 0.165021150 | 0.00100109 | 1.001090 | ms |
| delay_ms(1) | 0.166022250 | 0.00100110 | 1.001100 | ms |
| delay_ms(1) | 0.167023380 | 0.00100113 | 1.001130 | ms |
相比于1us,1ms就精确了许多。这是由于误差函数只运行了一遍,而基本函数为delay_us(1000),加上调用函数和循环函数的消耗,整体误差保持在千分之一数量级,也就是每1ms误差为1us。
(4)1s测试
| 代码段 | 时间点(s) | 差(s) | 耗时 | 单位 |
| 0.163003320 | ||||
| delay_s(1) | 1.163795720 | 1.00079240 | 1.000792 | s |
| delay_s(1) | 2.164588120 | 1.00079240 | 1.000792 | s |
| delay_s(1) | 3.165380560 | 1.00079244 | 1.000792 | s |
| delay_s(1) | 4.166172970 | 1.00079241 | 1.000792 | s |
同上,误差函数仅运行一遍,基本函数为delay_ms(1000),整体误差保持在万分之8数量级,也就是每1s误差为0.8ms。
(5)递增测试
| 代码段 | 时间点(s) | 差(s) | 耗时 | 单位 |
| 0.163024640 | ||||
| delay_us(1) | 0.163026260 | 0.00000162 | 1.620000 | us |
| delay_us(10) | 0.163036900 | 0.00001064 | 10.640000 | us |
| delay_us(100) | 0.163137460 | 0.00010056 | 100.560000 | us |
| delay_us(1000) | 0.164138080 | 0.00100062 | 1000.620000 | us |
| 代码段 | 时间点(s) | 差(s) | 耗时 | 单位 |
| 0.161016630 | ||||
| delay_ms(1) | 0.162017740 | 0.00100111 | 1.001110 | ms |
| delay_ms(10) | 0.172026080 | 0.01000834 | 10.008340 | ms |
| delay_ms(100) | 0.272106930 | 0.10008085 | 100.080850 | ms |
| delay_ms(1000) | 1.272912780 | 1.00080585 | 1000.805850 | ms |
| 代码段 | 时间点(s) | 差(s) | 耗时 | 单位 |
| 0.161005070 | ||||
| delay_s(1) | 1.161797490 | 1.00079242 | 1.000792 | s |
| delay_s(10) | 11.169718890 | 10.00792140 | 10.007921 | s |
| delay_s(100) | 111.248930290 | 100.07921140 | 100.079211 | s |
| delay_s(1000) | 1112.041041690 | 1000.79211140 | 1000.792111 | s |
在此测试中证明了以上结论。
1、在delay_us()函数中,由于参数不改变循环次数,故误差固定为0.6us左右,也就是误差函数时间+语言本身的函数误差时间。
2、在delay_ms()函数中,由于基本函数固定为delay_us(1000),加上其余误差,误差为万分之八至千分之一。
2、在delay_s()函数中,由于基本函数固定为delay_ms(1000),加上其余误差,误差为万分之八。
4.4 测试结论
在常用的System系统延时函数中,通过限制基本函数循环次数和内核高速度的特性将误差控制在可以接受的数量级中。
但此方法无法规避高精度延时的小时间基准误差(如1us级),即在1us延时中误差达到惊人的60%,这在部分环境中是无法接受的。例如,在工业控制场景中,1us延时误差可能导致传感器数据采集失准。
且误差大小取决于芯片的运行速度和编译特性。如果采用低速度的芯片,误差将会成倍增长。如果采用Python等解释性语言,那误差会飞到天上(lll¬ω¬)。
且此延时函数需要频繁的开关定时器,这在需要系统长时间稳定运行的情况下是极其危险的。
五、延时函数重构
先说实验结论。
1:System系统时钟在不频繁访问寄存器且在微妙级精度延时下的精确延时毫无意义。
2:System系统时钟的定时器会对内核造成负担,在微妙甚至毫秒级的定时器下不推荐使用系统时钟定时器,而应该使用外部定时器。
3:System系统时钟相关寄存器并非比较优秀的工具,很明显的感受到它的限制和压力。这种限制或许在更差的核心中会更加明显,而超高速度超高稳定性的内核中或许可以缓解。
故在使用System时钟时,应注意以下问题:
(1):尽量只开启一次System时钟,且在系统生命周期中永不关闭。
(2):尽量只在初始化时写入System寄存器,在之后仅读取。
(3):尽量不使用System中断,若要使用尽量将中断周期控制在毫秒级以上,且中断时间控制在微秒级。
(4):延时函数仅在100us(数量级)以上的延时可以控制百分之5级别的高精度。除非不使用函数,但在实际使用中非常复杂且意义不明。
5.1 延时函数重构
我重构了一种System延时函数,作为抛砖引玉的作用。期待更多大佬有更好的方法。
只写了xms延时,同逻辑可以写xs延时。
5.1.1 模块源代码及头文件:
MYDELAY.c
#include "stm32f10x.h"
//System时钟初始化
void MYSysTick_Init(void)
{
SysTick->CTRL=0x04; //关闭定时器
SysTick->LOAD=72*1-20; //设置定时器重装值
SysTick->CTRL=0x05; //设置时钟源为HCLK,中断不使能,启动定时器
SysTick->VAL=0x00; //清空当前计数器
while(!(SysTick->CTRL & 0x00010000)); //等待计数到0
}
//1us基准延时
void MYdelay1us(void)
{
SysTick->VAL=0; //清空当前计数器
while(!(SysTick->CTRL & 0x00010000));
}
//xms延时函数
void MYdelay_ms(int ms)
{
ms=ms*1000/1.222225; //误差归零
while(ms--)
{
MYdelay1us();
}
}
MYDELAY.h
#ifndef __MYDELAY_H
#define __MYDELAY_H
void MYSysTick_Init(void);
void MYdelay1us(void);
void MYdelay_ms(int ms);
#endif
5.1.2 重构的阻塞式延时函数模块详解
首先,我们仅在初始化中写入寄存器,重装值设定为72*1-20。
其中,72*1为72Mhz系统频率下的理论1us,而-20为误差归零值,此数值为经验值。
我们将系统中断关闭,因为我在实验中发现当系统中断开启时,即使中断函数为空也会造成误差,且此中断消耗内核资源,为极其危险的消耗。
在xms延时函数中,我们设定了一个除法级的误差归零。这是由于我们的延时函数为纯循环延时,循环造成的消耗误差是线性的。
由于32位int的除法特性,会直接忽略小数位,所以此延时函数在更大的延时数量级下会更加精准,而在此误差基准值为百万级。
这里要注意的是,在使用中要避免参数超限。
5.1.3 重构的阻塞式延时函数性能测试
同第四章的测试过程,在此直接给出一次实验数据(已经过多次论证)。
| 代码段 | 时间点(s) | 差(s) | 耗时 | 单位 |
| 0.000170890 | ||||
| MYdelay1us(); | 0.000171920 | 0.00000103 | 1.030000 | us |
| MYdelay_ms(1); | 0.001175650 | 0.00100373 | 1.003730 | ms |
| MYdelay_ms(10); | 0.011178600 | 0.01000295 | 10.002950 | ms |
| MYdelay_ms(100); | 0.111181100 | 0.10000250 | 100.002500 | ms |
| MYdelay_ms(1000); | 1.111181600 | 1.00000050 | 1000.000500 | ms |
| MYdelay_ms(10000); | 11.111162130 | 9.99998053 | 9999.980530 | ms |
可以看到,1us基准函数的误差为百分之三
1ms延时函数误差为千分之四
10ms延时函数误差为万分之三
100ms延时函数误差为十万分之三
1000ms延时函数误差为千万分之五
10000ms延时函数误差为百万分之二
实验符合预期,实践论证了理论。
六、结语
完成这篇文章比我预期中耗时长了许多,在这个过程中遇到了太多没有预料到的问题。这些困难和疑惑在文章中是体现不出来的。
这更加让我感觉到自身能力的欠缺,但路虽远,行则将至。
最后得出的结果却有些搞笑,我根本想不明白这些结论有什么现实意义。实际上大部分情况下并不需要如此高的精准度和稳定性,几乎没有情况会对阻塞式延时的精准度有这么高的需求,而精确的非阻塞式延时也不需要用System系统时钟,而稳定性方面,也不会有多少人像我一样用单片机暴力的连续写入还加上阻塞式延时函数吧...想到这些我突然释怀的笑。
但或许有些时候并不需要这个结果有多大的现实意义,就像哪怕我把这篇文章发在CSDN上,也没几个人会闲的蛋疼去看完。
感谢看完我的文章的极客们,本躯理论不足能力欠缺,若有错误恳请斧正。
也感谢根本没看懂也看完这篇文章的后辈们,如果你是这个领域的新生,那我可以给出一些建议:没有人比你更强,不要妄自菲薄,当你一步一步往前走,或许会发现我已经在你身后了。
临渊羡鱼,不如退而结网。
更多推荐

所有评论(0)