怎么让DMA完成内存到多个外设的散波传输 定时器产生带死区的互补PWM波 系统节拍器更新所有任务的时间片 用示波器测电源完整性 PID的串级控制 寄存器位带操作 、精度芯片勘误表和编程手册 、手写RTOS内核、掌握Simulink和LabVIEW、Visio 状态机、中断、DMA实现零CPU占用传输
总体架构:从物理世界到代码世界
嵌入式系统的本质,是用数字逻辑去控制和感知模拟的物理世界。理解这一点,是理解下面所有技术的第一性原理。
从物理层看,电流在导体中流动、电压在节点上建立、电磁场在空间中传播。从数学层看,控制系统用微分方程描述动态行为,用离散化方法将连续世界映射到数字计算。从信息层看,数据在总线、存储器和外设之间流动,需要高效、可靠的搬运机制。
下面这张图展示了各模块之间的逻辑关系——它们不是孤立的,而是一个完整的嵌入式控制系统的有机组成部分:
(AI生成)
text
┌─────────────────────────────────────────────────────────────────┐ │ 嵌入式系统全景架构 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ 传感器 │───▶│ 信号调理 │───▶│ ADC │───▶│ DMA │ │ │ │(物理量→电)│ │(滤波/放大)│ │(模拟→数字)│ │(数据搬运)│ │ │ └──────────┘ └──────────┘ └──────────┘ └────┬─────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ CPU / MCU 核心 │ │ │ │ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐ │ │ │ │ │ RTOS │ │ PID控制 │ │ 状态机 │ │ 位带操作 │ │ │ │ │ │ 调度器 │ │ 算法 │ │ 逻辑 │ │ 寄存器 │ │ │ │ │ └────┬────┘ └────┬─────┘ └──────────┘ └───────────┘ │ │ │ └───────┼────────────┼──────────────────────────────────────┘ │ │ │ │ │ │ ▼ ▼ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ 定时器 │───▶│ PWM │───▶│ 驱动电路 │───▶│ 执行器 │ │ │ │(计时/死区)│ │(互补输出)│ │(功率放大)│ │(电机/加热)│ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────────┐ │ │ │ 开发保障层 │ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ │ │ 芯片勘误 │ │ 示波器 │ │ 代码工具 │ │ │ │ │ │ + 编程手册│ │ 电源完整性│ │ Simulink │ │ │ │ │ └──────────┘ └──────────┘ └──────────┘ │ │ │ └──────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘
贯穿全文的第一性原理:嵌入式系统的每一个设计决策,本质上都是在时间、空间和精度之间做权衡。DMA用硬件换CPU时间;死区插入用微小的时间损失换取功率器件安全;RTOS用上下文切换开销换取任务响应性;位带操作用地址空间换原子性。理解了这个“三角权衡”,你就掌握了嵌入式系统设计的灵魂。
一、DMA:让数据搬运不再消耗CPU
1.1 第一性原理:为什么需要DMA?
数学视角:假设你有一个ADC以1MHz的速率采样,每次采样产生2字节数据。如果CPU用轮询方式搬运数据,假设每次搬运需要10个时钟周期,CPU在100MHz时钟下,每秒需要消耗 1M × 10 = 1000万个时钟周期,也就是10%的CPU算力被纯粹的数据搬运吃掉了。更糟糕的是,轮询是同步的——CPU必须时刻盯着ADC,无法同时做别的事。
物理类比:DMA就像一个专业的搬运工。CPU是“大脑”,擅长思考和决策;DMA是“手脚”,擅长重复性劳动。大脑让搬运工把货物从仓库A搬到仓库B,然后就可以去思考别的策略了。搬运工搬完后报告一声即可。
核心公式:总线带宽利用率 = 有效数据传输量 / (有效数据传输量 + 总线仲裁和开销)
DMA的目标就是让这个比值趋近于1。
1.2 关键技术点
传输方向与地址模式:DMA支持外设到存储器、存储器到外设、存储器到存储器三种方向。对于“内存到多个外设”的散波传输,核心思想是:同一块内存数据,依次搬运到多个外设的数据寄存器。
这需要理解DMA的两个地址指针:
-
源地址指针:指向内存中的同一位置(不递增,因为每次取同一份数据)
-
目标地址指针:初始指向第一个外设的数据寄存器,搬运N次后切换到第二个外设
STM32的DMA控制器通过DMA_SxCR寄存器中的PINC(外设地址递增)和MINC(存储器地址递增)位来控制地址是否自动递增。
循环模式与双缓冲:CIRC位使能循环模式后,DMA在一次传输完成后自动重新开始,周而复始。DBM位使能双缓冲模式后,DMA在两个存储器缓冲区之间自动切换,CPU可以在DMA读写一个缓冲区时处理另一个缓冲区的数据,实现真正的零等待。
1.3 “内存到多个外设”散波传输的配置思路
这里给出一个清晰的心智模型:把DMA想象成一个分发员,手里拿着一叠相同的传单(内存数据),要分发给多个不同的窗口(外设)。
分步配置:
-
选择DMA控制器和通道:不同外设映射到不同的DMA通道,需要查阅参考手册的DMA请求映射表。
-
配置源地址:指向内存缓冲区首地址,设置
MINC=0(存储器地址不递增,因为每次分发同一份数据)。 -
配置目标地址:指向第一个外设的数据寄存器,设置
PINC=1(外设地址递增,实现跨外设分发)。 -
设置数据宽度:外设和存储器的数据宽度需要匹配(8/16/32位)。
-
设置传输数量:
NDTR寄存器设置为 数据长度 × 外设数量。 -
使能传输完成中断:所有外设都收到数据后,DMA产生中断通知CPU。
代码示例(以STM32 HAL库风格示意):
(AI生成)
c
// 假设要发送同一组数据到3个SPI外设 // 内存数据:uint8_t txBuffer[256] // 目标:SPI1->DR, SPI2->DR, SPI3->DR DMA_HandleTypeDef hdma; // 配置DMA:内存到外设,循环模式 hdma.Instance = DMA1_Stream0; hdma.Init.Channel = DMA_CHANNEL_0; hdma.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma.Init.PeriphInc = DMA_PINC_ENABLE; // 外设地址递增 hdma.Init.MemInc = DMA_MINC_DISABLE; // 内存地址不递增 hdma.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma.Init.Mode = DMA_CIRCULAR; // 循环模式 hdma.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma);
关键点:STM32的DMA控制器通常一个通道只能服务一个外设。要实现“内存到多个外设”,需要利用DMA的“存储器到存储器”模式配合多个DMA通道,或者使用定时器触发不同通道的DMA请求,轮流将数据搬运到不同外设。这在STM32H7等高端型号的GPDMA上支持更好。
1.4 实际应用建议
-
音频/I2S场景:用DMA双缓冲模式,CPU处理前一个缓冲区时,DMA填充后一个缓冲区,实现无缝音频播放。
-
ADC多通道扫描:DMA循环模式将ADC多通道结果自动搬运到内存数组,CPU零干预。
-
注意Cache一致性:如果MCU有D-Cache(如Cortex-M7),DMA写入的内存区域需要手动失效Cache,否则CPU读到的是旧数据。
二、定时器带死区的互补PWM
2.1 第一性原理:为什么需要死区?
物理模型:想象一个半桥电路——两个MOSFET上下串联,中间点驱动电机。上管导通时,电流从电源正极流向电机;下管导通时,电流从电机流向地。如果两个管子同时导通,电源正极直接短路到地,瞬间产生巨大电流,管子炸掉。
数学描述:MOSFET不是理想开关。它有开通延迟(td(on))和关断延迟(td(off))。在关断过程中,VGS从高电平下降到阈值电压Vth需要时间。在这个时间内,管子仍然导通。
死区时间 tdead 必须满足:
tdead>td(off)−td(on)
对于典型MOSFET,这个值在100ns到1μs之间。
PWM占空比与死区的关系:死区时间的插入会略微降低有效占空比,实际输出占空比为:
Deff=Dideal−tdeadTPWM
在低频PWM中,这个影响可以忽略;在高频(如100kHz以上)且高占空比场景下,需要补偿。
2.2 关键技术点
STM32高级定时器(如TIM1、TIM8)可以产生多达三个带死区插入的互补输出。配置时需要:
-
将通道及其互补通道对应的输出比较使能位(
OCxE和OCxNE)置1。 -
在刹车与死区寄存器
BDTR中设置DTG[7:0]位,计算死区时间。
死区时钟Tdtg的计算公式:
-
如果
DTG[7] = 0:Tdtg=TDTSTdtg=TDTS -
如果
DTG[6] = 0:Tdtg=2×TDTSTdtg=2×TDTS -
如果
DTG[5] = 0:Tdtg=8×TDTSTdtg=8×TDTS -
如果
DTG[7:5] = 111:Tdtg=16×TDTSTdtg=16×TDTS
其中TDTS由时钟分频器CKD[1:0]决定。
死区时间计算:
-
DTG[7]=0DTG[7]=0:死区时间 = DTG[7:0]×TdtgDTG[7:0]×Tdtg
-
DTG[6]=0DTG[6]=0:死区时间 = (64+DTG[5:0])×Tdtg(64+DTG[5:0])×Tdtg
-
DTG[5]=0DTG[5]=0:死区时间 = (32+DTG[4:0])×Tdtg(32+DTG[4:0])×Tdtg
2.3 代码示例
(AI生成)
c
// STM32高级定时器TIM1配置互补PWM带死区 TIM_HandleTypeDef htim1; TIM_OC_InitTypeDef sConfigOC; TIM_BreakDeadTimeConfigTypeDef sBreakDeadTimeConfig; // 时基配置:PWM频率 = 72MHz / (72 * 1000) = 1kHz htim1.Init.Prescaler = 72 - 1; htim1.Init.Period = 1000 - 1; htim1.Init.CounterMode = TIM_COUNTERMODE_UP; htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter = 0; // PWM通道配置 sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 500; // 50%占空比 sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCNPolarity = TIM_OCNPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; sConfigOC.OCIdleState = TIM_OCIDLESTATE_RESET; sConfigOC.OCNIdleState = TIM_OCNIDLESTATE_RESET; // 死区配置:约500ns sBreakDeadTimeConfig.OffStateRunMode = TIM_OSSR_ENABLE; sBreakDeadTimeConfig.OffStateIDLEMode = TIM_OSSI_ENABLE; sBreakDeadTimeConfig.LockLevel = TIM_LOCKLEVEL_OFF; sBreakDeadTimeConfig.DeadTime = 72; // DTG值,对应约500ns sBreakDeadTimeConfig.BreakState = TIM_BREAK_ENABLE; sBreakDeadTimeConfig.BreakPolarity = TIM_BREAKPOLARITY_HIGH; sBreakDeadTimeConfig.AutomaticOutput = TIM_AUTOMATICOUTPUT_ENABLE;
2.4 实际应用建议
-
死区时间宁大勿小:先设置一个安全的较大死区(如1μs),用示波器观察波形确认无直通,再逐步减小。
-
对于电机驱动,死区补偿可以改善低速时的转矩脉动。
-
使用刹车输入(Break Input)连接过流保护信号,硬件级快速关断PWM。
三、系统节拍与RTOS任务调度
3.1 第一性原理:时间片是什么?
操作系统视角:单核CPU没有真正的“同时运行”。RTOS把CPU时间切成一个个小片,每个任务轮流使用。这个“小片”就是时间片,由硬件定时器产生的周期性中断(Tick中断)来驱动。
数学类比:这就像分蛋糕。假设每秒CPU有1000个时间片(Tick频率1kHz),3个同优先级任务各分1/3的时间片。每个任务在每个时间片内运行,时间片用完就被换下,下一个任务上场。
时间片轮转调度的效率取决于:
有效计算占比=1−tcontext_switchttick
其中tcontext_switch是上下文切换开销。在Cortex-M上,一次上下文切换大约需要1-2μs。如果Tick频率是1kHz(时间片1ms),开销占比约0.1-0.2%。
FreeRTOS的机制:FreeRTOS默认同时使用两种调度策略——抢占式调度(不同优先级之间,高优先级随时抢占)和时间片轮转(同优先级之间,每人轮流跑一个时间片)。
核心原则是优先级高于时间片:高优先级任务可以随时打断低优先级任务,不管低优先级任务的时间片有没有用完。
3.2 关键技术点
SysTick中断:每次SysTick中断(默认1ms)做两件事:tick计数+1,检查阻塞任务是否到期需要唤醒,然后判断是否需要触发任务切换。时间片轮转的判断逻辑就嵌在xTaskIncrementTick()函数中。
上下文切换:利用Cortex-M的PendSV异常实现。PendSV的优先级被设为最低,目的是让所有业务中断先处理完,再做任务切换,不影响中断实时性。
任务控制块(TCB) :每个任务有一个TCB,保存栈顶指针、栈起始地址、栈大小、优先级、任务状态等信息。上下文切换时,当前任务的寄存器值被压入其任务栈,然后从下一个任务的栈中恢复寄存器值。
3.3 代码示例:手动触发时间片轮转
(AI生成)
c
// FreeRTOS中时间片轮转的简化逻辑
void xTaskIncrementTick(void) {
TickType_t xTickCount = 0;
xTickCount++; // 系统节拍递增
// 检查延时列表中的任务是否到期
if (xTickCount >= xNextTaskUnblockTime) {
// 唤醒到期任务...
}
// 时间片轮转判断
#if (configUSE_TIME_SLICING == 1)
if (listCURRENT_LIST_LENGTH(&(pxReadyTasksLists[pxCurrentTCB->uxPriority])) > 1) {
// 同优先级就绪链表中有多个任务
// 当前任务的时间片用完了
taskYIELD(); // 触发任务切换
}
#endif
}
3.4 实际应用建议
-
Tick频率的取舍:太小→切换频繁,CPU开销大;太大→同优先级任务响应延迟增大。一般1kHz(1ms)是平衡点。
-
延时必须用
pdMS_TO_TICKS()宏换算,不要写死tick数,否则改变Tick频率后行为异常。 -
空闲任务不可缺:所有用户任务阻塞时,CPU至少有一个任务可跑,同时空闲任务负责回收被删除任务的TCB和栈资源。
四、用示波器测电源完整性
4.1 第一性原理:你测到的真的是电源纹波吗?
物理模型:示波器探头和地线构成一个测量回路。这个回路有寄生电感Lloop,与探头输入电容Cin构成一个LC谐振电路。谐振频率为:
fresonance=12πLloop⋅Cin
一根15cm的鳄鱼夹地线,寄生电感约100-150nH。探头输入电容约10pF。谐振频率约4-5MHz。这意味着:电源轨上任何超过这个频率的噪声,测量回路自己会“制造”出额外的振铃,让你误以为电源纹波很大。
核心结论:你测到的“纹波” = 电源真实纹波 + 测量回路引入的伪影。
4.2 关键技术点
正确的测量方法:
-
使用短地弹簧:探头尖端接输出电容正端,接地弹簧接输出电容负端,把测量环路面积压到最小。地线长度控制在3cm以内。
-
使用AC耦合:切到AC耦合挡位,把几伏的直流分量挡掉,只放大看毫伏级交流纹波。但注意:AC耦合会屏蔽掉很慢的直流变化,测负载瞬态时可能需要DC耦合。
-
打开20MHz带宽限制:用来观察datasheet里常见的低频纹波指标。但如果要排查高频尖峰,就不能开带宽限制。
-
示波器平均模式:设置1024次平均,随机噪声被滤除,可以清晰辨别周期性纹波。
4.3 实际应用建议
-
先验证测量方法再改电路:用长地夹测一次,保存波形;换成短地弹簧再测一次。如果高频尖峰明显缩小,说明原来的测量方式引入了很大误差。
-
测开关电源纹波时,探头衰减比选择:10X探头会把信号衰减10倍,测几毫伏纹波时信号接近示波器噪声底。考虑使用1:1探头或专用Power Rail Probe。
-
区分稳态纹波和负载瞬态:两者需要不同的示波器设置,不要混淆。
五、PID串级控制
5.1 第一性原理:为什么单回路PID不够?
控制理论视角:一个典型的工业过程(如锅炉温度控制)包含多个环节:燃料流量→燃烧→炉膛温度→传热→水温。这是一个大滞后、多变量耦合的系统。
用单回路PID控制水温:PID输出直接控制燃料阀门。问题在于——燃料流量变化后,炉膛温度变化需要时间,水温变化需要更长时间。在这段滞后时间内,PID还在根据旧的水温误差调整阀门,导致超调甚至振荡。
串级控制的数学描述:
单回路系统:Gplant(s)=G1(s)⋅G2(s)⋅G3(s),其中每个环节都有延迟。
串级系统:
-
副回路:C2(s)⋅G2(s)C2(s)⋅G2(s),快速抑制进入副回路的干扰
-
主回路:C1(s)⋅C2(s)G2(s)1+C2(s)G2(s)⋅G3(s)C1(s)⋅1+C2(s)G2(s)C2(s)G2(s)⋅G3(s)
副回路把G2(s)这个“快环节”先稳定住,主回路面对的是一个“等效更快”的被控对象。
关键设计准则:副回路的时间常数应该是主回路的1/10以下,否则串级效果不明显。
5.2 串级PID的物理类比
把串级控制想象成公司管理:
-
主回路(外环):CEO制定年度目标(水温目标),根据市场反馈(水温)调整部门目标(蒸汽压力目标)。
-
副回路(内环):部门经理(蒸汽压力控制器)快速响应CEO的目标变化,直接管理一线员工(燃料阀门)。
CEO不需要每时每刻盯着每个员工,部门经理负责快速调节。CEO只关注整体战略。
5.3 代码示例:双回路串级PID
(AI生成)
c
// 串级PID结构体
typedef struct {
float Kp, Ki, Kd;
float integral;
float prev_error;
float output;
float output_min, output_max;
} PID_Controller;
// 主回路(温度→压力目标):100ms周期
// 副回路(压力→阀门):10ms周期
// 主回路每10次副回路中断执行一次
volatile PID_Controller temp_pid; // 主回路
volatile PID_Controller press_pid; // 副回路
// 10ms定时器中断服务函数
void Timer0_ISR(void) {
static uint16_t cnt = 0;
// 1. 副回路:每次中断都执行(10ms周期)
float press_actual = Read_Steam_Press();
PID_Calculate(&press_pid, press_pid.target, press_actual);
Set_Valve_Opening(press_pid.output);
// 2. 主回路:每10次中断执行一次(100ms周期)
if (++cnt >= 10) {
cnt = 0;
float temp_actual = Read_Water_Temp();
PID_Calculate(&temp_pid, temp_pid.target, temp_actual);
// 主PID的输出作为副PID的目标值
press_pid.target = temp_pid.output;
}
}
5.4 实际应用建议
-
先整定副回路,再整定主回路:副回路控制器设为纯比例,从小到大增大Kp直到临界振荡,取临界增益的1/2作为工作增益。然后整定主回路。
-
副回路通常用纯比例控制即可,主回路用PI或PID。
-
异步采样:主回路采样周期是副回路的整数倍(如10倍),节省CPU资源。
六、寄存器位带操作
6.1 第一性原理:为什么要“位带”?
计算机体系结构视角:Cortex-M的存储器系统以字节为最小寻址单位。你不能直接操作一个地址的某一位。要设置GPIO的某个引脚,常规做法是:
c
GPIOA->ODR |= (1 << 1); // 读-改-写三步操作
问题在于:这三步操作不是原子的。如果在读和写之间发生中断,且中断中修改了同一个寄存器,数据就会丢失。
位带操作的解决方案:Cortex-M3/M4内核开辟了一块位带别名区(Bit-band Alias),将位带区的每一个位映射到别名区的一个32位字。对别名区地址的读写,硬件自动转换为对位带区对应位的原子读-改-写操作。
映射关系(这是纯数学的地址变换):
-
SRAM区:
AliasAddr = 0x22000000 + (A - 0x20000000) × 32 + n × 4 -
外设区:
AliasAddr = 0x42000000 + (A - 0x40000000) × 32 + n × 4
其中A是位带区字节地址,n是该字节内的位序号(0-7)。
为什么×32? 位带区1个字节 = 8位,映射到别名区8个字 = 32字节。所以地址增量是32倍。
6.2 代码实现
(AI生成)
c
// 位带操作宏定义
#define BITBAND_RAM(addr, bit) \
(*((volatile uint32_t *)(0x22000000 + \
(((uint32_t)&(addr) - 0x20000000) << 5) + ((bit) << 2))))
#define BITBAND_PERIPH(addr, bit) \
(*((volatile uint32_t *)(0x42000000 + \
(((uint32_t)&(addr) - 0x40000000) << 5) + ((bit) << 2))))
// 使用示例
// 原子地设置GPIOA的PA1引脚为高
BITBAND_PERIPH(GPIOA->ODR, 1) = 1;
// 原子地读取PA1引脚状态
uint8_t state = BITBAND_PERIPH(GPIOA->IDR, 1);
6.3 实际应用建议
-
位带操作是Cortex-M3/M4的经典特性,但后续的Cortex-M处理器(如M7、M33)已不再支持。从软件兼容性角度,不建议在新项目中使用。
-
现代替代方案:使用
__atomic系列内建函数,或使用LDREX/STREX指令实现原子操作。 -
位带操作最大的价值在于理解原子操作的本质——它教会你“为什么读-改-写不是原子的”以及“硬件如何提供原子性”。
七、芯片勘误表与编程手册
7.1 第一性原理:为什么芯片会有“错误”?
半导体物理视角:芯片设计是极其复杂的工程。一个现代MCU包含数千万甚至数亿个晶体管。在流片之前,设计验证覆盖率达到99.9%就已经是业界顶尖水平。剩下的0.1%意味着数千个潜在问题。
勘误表(Errata Sheet) 是芯片厂商对已知硬件缺陷的官方记录。它描述的是:芯片实际行为与数据手册描述之间的细微差异,以及建议的变通方法(Workaround) 。
关键认知:勘误表不是“可选阅读材料”,它是产品开发必读文档。一个典型的例子:某些STM32型号的DMA在特定条件下会丢失数据;某些ESP32型号的ADC在特定配置下精度不足。不了解这些,你的产品可能在客户现场才暴露问题。
7.2 关键技术点
勘误表的典型结构:
-
芯片版本标识:通过eFuse位或芯片丝印识别芯片的硅片版本(Revision)。
-
勘误摘要矩阵:列出每个勘误项影响哪些版本,快速判断你的芯片是否受影响。
-
详细描述:每个勘误项包含问题描述、影响、变通方法和参考代码。
编程手册(Programming Manual) :描述CPU内核、外设的编程模型(寄存器、指令集、异常模型)。参考手册(Reference Manual)描述具体外设的详细功能。数据手册(Datasheet)描述电气特性和引脚定义。
7.3 实际应用建议
-
项目启动第一件事:下载并阅读目标芯片的勘误表,确认你的芯片版本和计划使用的功能是否有已知问题。
-
建立勘误表检查清单:把与你项目相关的勘误项列出来,在代码审查时逐项检查。
-
勘误表会随着芯片版本更新。如果更换了芯片批次,重新检查勘误表。
八、手写RTOS内核
8.1 第一性原理:从裸机到RTOS
裸机的问题:一个大循环while(1)里放了传感器采集、按键扫描、LED刷新。如果传感器采集需要等待200ms,这200ms内按键和LED全部冻结。
RTOS的解决方案:把每个功能封装成独立的任务,每个任务有自己的栈和优先级。调度器按规则分配CPU时间。任务等待时主动“睡觉”,把CPU让给其他任务。
调度器必须回答的四个问题:
-
有哪些任务在等着运行?(就绪队列的维护)
-
在这些任务里应该选哪一个?(调度算法)
-
被选中的任务怎么开始跑?(上下文切换)
-
什么时候重新做一次选择?(调度触发时机)
8.2 最小RTOS内核实现
任务控制块(TCB) :
(AI生成)
c
typedef struct task_control_block {
void *stack_top; // 任务栈顶指针
void *stack_start; // 栈起始位置
uint32_t stack_size; // 栈大小
uint32_t priority; // 优先级
enum task_state state; // 任务状态
} TCB;
上下文切换的核心:利用Cortex-M的PendSV异常。PendSV优先级设为最低,确保所有中断处理完后才做任务切换。
(AI生成)
c
// 触发任务切换
void task_yield(void) {
// 设置PendSV挂起位
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
}
// PendSV中断服务函数(汇编实现)
__attribute__((naked)) void PendSV_Handler(void) {
__asm volatile (
"MRS R0, PSP\n" // 获取当前任务栈指针
"STMFD R0!, {R4-R11}\n" // 保存寄存器
"LDR R1, =current_tcb\n" // 保存栈顶到TCB
"LDR R2, [R1]\n"
"STR R0, [R2]\n"
// 选择下一个任务...
"LDMFD R0!, {R4-R11}\n" // 恢复寄存器
"MSR PSP, R0\n"
"BX LR\n"
);
}
SysTick驱动调度:SysTick中断中递增tick计数,检查延时任务,并调用task_yield()触发调度。
8.3 实际应用建议
-
手写RTOS的核心价值在于理解机制,不是为了替代FreeRTOS。
-
关键学习点:栈帧布局、PendSV切换、就绪队列管理、临界区保护。
-
建议从最简单的“两个任务轮流跑”开始,逐步增加延时、优先级、信号量等机制。
九、掌握Simulink、LabVIEW、Visio
9.1 各自定位与第一性原理
| 工具 | 核心用途 | 第一性原理 |
|---|---|---|
| Simulink | 模型在环(MIL)、自动代码生成 | 用图形化方式求解微分方程,自动生成嵌入式C代码 |
| LabVIEW | 数据采集、仪器控制、测试自动化 | 数据流编程:节点在输入数据就绪时执行 |
| Visio | 系统架构图、流程图、状态机图 | 用标准符号表达系统结构和行为 |
9.2 应用建议
-
Simulink:用于PID控制算法的模型验证和自动代码生成。可以先在Simulink中仿真串级PID,验证参数后再自动生成C代码部署到MCU。
-
LabVIEW:用于产线测试,编写自动化测试脚本控制示波器、电源、信号发生器。
-
Visio:用于设计评审,画出系统状态机和架构图,团队沟通效率远高于文字描述。
十、状态机 + 中断 + DMA 实现零CPU占用传输
10.1 第一性原理:什么是“零CPU占用”?
严格来说,CPU不可能完全为0——初始化配置、中断处理都需要CPU。但数据传输本身的CPU占用可以趋近于0。
实现路径:定时器触发DMA,DMA搬运数据,DMA传输完成中断通知CPU做最少量的处理。
text
定时器溢出 ──▶ DMA请求 ──▶ 数据搬运(硬件) ──▶ 传输完成中断 ──▶ CPU仅做标志置位
(硬件) (硬件) (硬件) (中断)
物理类比:这就像自动灌溉系统。定时器是“闹钟”,到点就响;DMA是“水管”,闹钟一响就自动浇水;CPU是“园丁”,只在水浇完后过来检查一下,平时可以去做别的事。
10.2 状态机设计
(AI生成)
c
typedef enum {
STATE_IDLE,
STATE_RECEIVING,
STATE_PROCESSING,
STATE_TRANSMITTING,
STATE_ERROR
} SystemState;
volatile SystemState sys_state = STATE_IDLE;
volatile uint8_t dma_complete_flag = 0;
// DMA传输完成中断
void DMA_TransferComplete_ISR(void) {
dma_complete_flag = 1;
sys_state = STATE_PROCESSING;
}
// 主循环中的状态机
void main_loop(void) {
while (1) {
switch (sys_state) {
case STATE_IDLE:
if (start_condition) {
sys_state = STATE_RECEIVING;
Start_DMA_Receive();
}
break;
case STATE_PROCESSING:
if (dma_complete_flag) {
dma_complete_flag = 0;
Process_Data();
sys_state = STATE_TRANSMITTING;
Start_DMA_Transmit();
}
break;
case STATE_TRANSMITTING:
if (dma_complete_flag) {
dma_complete_flag = 0;
sys_state = STATE_IDLE;
}
break;
}
}
}
10.3 实际应用建议
-
UART空闲中断 + DMA:这是最经典的“零CPU占用”接收方案。UART接收一帧数据后触发空闲中断,CPU处理数据,期间DMA自动搬运每个字节。
-
ADC定时触发 + DMA循环:定时器以固定频率触发ADC采样,DMA自动将结果搬运到内存数组。CPU只在数组满时处理。
综合应用案例:三相交错并联Buck变换器控制器
系统需求:设计一个数字电源控制器,驱动三相交错并联Buck变换器,输出电压12V,最大电流100A。
技术选型:
-
MCU:STM32G474(带高分辨率定时器HRTIM,适合数字电源)
-
拓扑:三相交错,每相120°相位差
-
控制:电压外环+电流内环串级PID
系统架构图:
text
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 12V输入 │────▶│ 三相Buck │────▶│ 输出滤波 │────▶│ 负载 │
│ │ │ (3×MOSFET)│ │ (LC) │ │ │
└──────────┘ └────┬─────┘ └────┬─────┘ └──────────┘
│ │
┌────▼─────┐ ┌────▼─────┐
│ 3×驱动器 │ │ 电压/电流 │
│(带死区) │ │ 采样电路 │
└────┬─────┘ └────┬─────┘
│ │
┌────▼─────────────────▼─────┐
│ STM32G474 │
│ ┌──────────────────────┐ │
│ │ HRTIM: 3对互补PWM │ │
│ │ (120°相位差+死区) │ │
│ ├──────────────────────┤ │
│ │ ADC: 电压/电流采样 │ │
│ │ (定时器触发+DMA) │ │
│ ├──────────────────────┤ │
│ │ 串级PID: 电压→电流 │ │
│ ├──────────────────────┤ │
│ │ 状态机: 软启动/运行/ │ │
│ │ 保护 (过流/过压) │ │
│ └──────────────────────┘ │
└────────────────────────────┘
工作流程:
-
HRTIM产生三对120°相位差的互补PWM,死区时间约200ns
-
ADC由HRTIM触发,在每个PWM周期中点采样电感电流和输出电压
-
DMA自动将ADC结果搬运到内存数组
-
电流内环在每个PWM周期执行PID计算(约10μs周期),更新占空比
-
电压外环每100个PWM周期执行一次(约1ms),输出电流环目标值
-
状态机监控过流/过压事件,触发刹车输入硬件级关断
应用建议(总结)
-
DMA优先:任何周期性数据传输都应该考虑DMA。CPU的时间应该花在“决策”上,而不是“搬运”上。
-
死区安全第一:功率电路调试时,先用大死区确认无直通,再逐步优化。
-
测量方法决定测量结果:测电源纹波前,先验证你的探头和接地方式。错误的测量方式比没有测量更危险。
-
勘误表是必读文档:项目启动时花30分钟读勘误表,可能节省30天的现场调试。
-
RTOS不是万能药:简单的系统用状态机+中断就够了。引入RTOS会增加复杂性和调试难度。只有当系统确实需要多任务并发和优先级调度时,才使用RTOS。
-
串级PID的调试顺序:先内环后外环,先比例后积分再微分。内环调不好,外环再完美也没用。
-
代码复用来自理解:不要盲目复制粘贴代码。理解每一行代码背后的物理意义和数学原理,才能在出问题时快速定位。
-
工具服务于思想:Simulink、LabVIEW、Visio都是工具,核心是你对系统建模和控制理论的理解。工具只是把你的理解表达出来。
寒山转苍翠,秋水日潺湲。——王维《辋川闲居赠裴秀才迪》
75



被折叠的 条评论
为什么被折叠?



