简介:面向嵌入式入门者的电动车控制器C语言代码概要文档,以W79E83X系列单片机为平台,围绕电机控制、辅助功能与安全保护三条主线展开,适合已掌握C语言基础语法、希望理解真实硬件编程的初级学习者对照研读。压缩包内仅1个doc文件,约97KB,篇幅紧凑,集中呈现主函数、I/O初始化与各功能模块的调用关系。内容从#include头文件与端口寄存器配置讲起,依次给出霍尔信号采样、相位变换、助力使能与PWM占空比设定、定速巡航最低速与进入延时、过流上限、欠压阈值换算、EABS滑行充电与电刹车开关,以及硬件四档限速和软件限速参数,并在注释中标注取值含义与推算过程。读者可据此梳理控制器从初始化到主循环的骨架,理解推拉、开漏、高阻等端口模式的配置方法,以及保护参数的整定思路。目前已有1133人学习。
1. 主循环里什么都没干,实时性却一点没丢
打开这份电动车控制器的 C 语言源代码,最反直觉的一行在main()末尾:一个while(1),里面只有一个_nop_(),其余全是被注释掉的功能函数。一个要驱动三相无刷电机、管限流、欠压、巡航、电刹车的系统,主循环居然是空转的。原因并不难想:换相时刻、过流关断窗口都是微秒到百微秒级的事,任何一次阻塞等待都会让功率管在错误的位置上多导通一段时间,轻则抖动发热,重则上下桥直通炸管。所以这套代码把实时逻辑全部压进中断,主循环只负责把 CPU 交出去。搞懂中断怎么分层、采样怎么在中断里算完、换相表为什么那样写,比记住任何一个功能函数都值钱。
2. 中断分层:100us 节拍怎么把三路 ADC 轮询成“伪并行”
一颗 8051 内核的单片机,没有 DMA,也没有 RTOS,电流、转把、电池电压三路模拟量共用一个 ADC,同时还要跑 PWM 调整、换相和保护判断。能用的资源只有一个定时器和几个中断源。这份代码的做法是把时间切成固定片:定时器中断当调度器,ADC 中断当搬运工,不同功能挂在不同时间粒度上,用节拍换取确定性。
2.1 100us 定时器里藏着一个采样状态机
Init()里对定时器 0 的配置很关键:TH0=0x50; TL0=0x50; TMOD=0x22;把 T0 设成模式 2,也就是 8 位自动重装载,CKCON&=0x00把它挂在 1/12 系统时钟上。模式 2 的好处是溢出后自动从 TH0 重装,不需要在中断里手动写初值,省掉两条指令,也避免了重装时机不稳导致的节拍抖动。注释标的是 100us,这个值不是随便定的,它同时是采样率、保护响应时间和功能调度周期的公共基准。
// ========== Interrupt Cycle: 100uS =================== void T0M1_ISR(void) interrupt 1 { ADC_Ready = 0; Current_REQ = 1; // 本拍先采电流 Speed_REQ = 0; Voltage_REQ = 0; ADCCON = 2; // 选通电流通道 Count_Speed++; KS_CNT++; AH_Count++; if (Count_Speed > 5) // 每 6 拍(600us)切到转把 { ADCCON = 4; Current_REQ = 0; Speed_REQ = 1; Count_Speed = 0; Count_Voltage++; if (Count_Voltage > 5) // 每 6×6 拍(3.6ms)切到电池电压 { ADCCON = 3; Speed_REQ = 0; Voltage_REQ = 1; Count_Voltage = 0; KS_Finish(); } } // ... 功能调度部分见 2.3 Volt_Low(); // 欠压保护 if (P02 == 0) Brake_Setting(); ADCCON &= 0xef; ADCCON |= 0x08; EADC = 1; PWM_ADJ(); // 本拍最后算占空比 }这段代码本质是一个三级嵌套计数器构成的状态机:Current_REQ每拍置位,所以电流采样率是 10kHz;Count_Speed>5才切一次转把,转把采样率降到约 1.67kHz;Count_Voltage>5再降一级,电池电压约 278Hz。三路带宽差了两个数量级,正好对应各自的物理特性:电流要抓瞬时尖峰,人拧转把最多几十赫兹,电池电压本身被大电容滤得平平的,采太快纯属浪费。
顺序也有讲究。ADC_Ready=0先清标志,再发通道选择,最后才开EADC允许转换中断,保证本次转换完成时进的一定是这一次的 ADC 中断,不会被上一拍的残局干扰。而PWM_ADJ()放在最后,是因为它要用本拍刚更新过的Current_SUM和Speed_SUM算占空比,先判保护再算输出,逻辑上不能颠倒。
2.2 环形缓冲与 SUM 增量维护:不用浮点的滑动平均
ADC 中断里没有做任何滤波运算,只做了一件事:把新样本塞进环形缓冲,同时把总和更新到位。
void ADC_ISR(void) interrupt 11 { ADC_Ready = 1; ADCCON &= 0xE7; if (Current_REQ) // 电流通道 { Current_REQ = 0; if (Current_SUM > Current_Buffer[Current_P]) Current_SUM -= Current_Buffer[Current_P]; // 先减掉将被覆盖的旧值 Current_Buffer[Current_P] = ADCH; Current_SUM += ADCH; // 再加入新值 Current_P++; if (Current_P > 31) Current_P = 0; // 32 点环形 } if (Speed_REQ) // 转把通道 { Speed_REQ = 0; if (Speed_SUM > Speed_Buffer[Speed_P]) Speed_SUM -= Speed_Buffer[Speed_P]; Speed_Buffer[Speed_P] = ADCH; Speed_SUM += ADCH; Speed_P++; if (Speed_P == 14) Speed_P = 0; // 实际只有 14 点 } if (Voltage_REQ) // 电池电压通道 { Voltage_REQ = 0; if (Voltage_SUM > Voltage_Buffer[Voltage_P]) Voltage_SUM -= Voltage_Buffer[Voltage_P]; Voltage_Buffer[Voltage_P] = ADCH; Voltage_SUM += ADCH; Voltage_P++; if (Voltage_P > 15) Voltage_P = 0; // 16 点环形 } }这段是整个文件里最值得学的部分。如果每来一个样本就遍历 32 个元素求平均,10kHz 采样率下光求和就要跑掉大量机器周期;改成“减旧值、加新值”之后,Current_SUM在任何时刻都精确等于窗口内 32 个样本之和,求均值只要SUM/32或者直接拿 SUM 跟一个放大 32 倍的阈值比,O(1) 复杂度,不用浮点,不用除法。这是 8 位机上最经典的滑动平均实现,血泪经验换来的。
窗口长度直接决定滤波带宽。按经验估算,N 点滑动平均的 -3dB 带宽在fs/(2πN)量级:电流 10kHz 采 32 点,带宽约 50Hz,足够滤掉 PWM 开关噪声又不至于让限流反应变慢;电压 278Hz 采 16 点,带宽只剩几赫兹,等于顺手做了一个很强的低通。三个窗口长度不是随手写的,是跟各自的噪声谱和响应要求对上的。
if (Current_SUM > Current_Buffer[Current_P])这个判断也得留意。它是无符号减法的保护,防止“总和比旧值还小”时下溢成一个大数。在正常情况下这个条件永远成立,但一旦缓冲区初值、指针错位或者中断重入出问题,它就是最后一道兜底。
2.3 功能调度:助力、巡航、欠压共享同一个节拍
回到定时器中断的后半段,几个功能都是靠累加计数触发,而不是各自开定时器:
if (AH_Count >= 100) { AutoHelp(); AH_Count = 0; } // 100×100us = 10ms if (KS_CNT >= 3000) { KS_CNT = 0; Keep_Speed(); } // 3000×100us = 300ms Volt_Low(); if (P02 == 0) Brake_Setting();助力每 10ms 调一次,巡航状态机每 300ms 走一步,欠压判断每拍都做。这个频率分配对应main()里Keep_SpeedEN(1,0x20,6)的第三个参数——巡航要在设定点保持多久才真正进入巡航;AutoHelpEN(1,0x1AA,200)的第三个参数则是助力的持续时间。参数写在初始化里,真正的倒计时靠这两个计数器在中断里累加,主循环完全不参与。
代价是执行预算。T0 中断里塞了采样触发、五个功能调用、一次占空比计算,最坏路径必须严格小于 100us,否则节拍漂移,采样率、保护延时全部失真。验证方法很土但有效:在中断入口翻转一个空闲 IO,用示波器看高电平宽度,那个宽度就是中断执行时间。我一般要求它不超过周期的一半,留足余量。
3. 六步换相:三个霍尔引脚怎么变成一个 switch 表
无刷直流电机的转子位置靠霍尔传感器给。三个霍尔按 120° 电角度布置,用三个引脚读出 6 个有效状态码,每个状态码对应“哪两相通电”的唯一答案。这份代码把这个映射直接写成了 switch,一眼能看完,也一眼能看出问题。
3.1 状态码拼装与双读去抖
霍尔信号分散在 P12、P07、P20 三个不同端口上,这是 PCB 布线迁就引脚可用性的结果,软件负责把它们拼成一个 3 位整数。
void H_Sample(void) { CAPCON1 &= 0xF8; H1 = P12; H2 = P07; H3 = P20; do { State1 = H1 << 2; State1 += H2 << 1; State1 += H3; _nop_(); _nop_(); State2 = H1 << 2; State2 += H2 << 1; State2 += H3; } while (State1 != State2); // 两次读不一致就重来 H_State = State1; }do-while循环会一直跑到两次读结果相同为止。看上去是死循环风险,实际不会:霍尔输出是数字量,抖动窗口只有微秒级,两次读之间隔两个_nop_,最多循环几次就能稳定。这里采用“重复读直到一致”,没有采用“读三次取两次相同”的方式,是因为换相时刻不能等太久,多一次采样就多一段延迟,电机高速时这个延迟会直接变成换相超前或滞后。
为什么要去抖:霍尔信号在换相边界本来就处于临界状态,加上 PWM 开关噪声耦合到霍尔线上,单次误读会让代码跳到错误 case,给绕组通错相,电流方向反了,表现为电机一顿、电流尖峰。这段去抖在Init()里也被调用了一次——上电时while(P02==0) { H_Sample(); Phase_Detect(); },先把转子位置摸清楚再开 PWM。
3.2 换相表的写法规律:先关后开
进入Phase_Change()之前要先明白命名。六个桥臂被分成上桥VT/UT/WT和下桥VB/UB/WB,前者是三相上管,后者是三相下管。每个 case 分支里都遵循同一个写入顺序,把case 6拆开看最清楚:
case 6: // 110 VT = 0; UT = 0; // 先把上桥全部拉到关断态 VB = 1; WB = 1; _nop_(); // 空转一个机器周期,等电平真正建立 WT = 1; UB = 0; // 再单独打开本状态需要的管子 break;规律是:先写一遍三个上桥,把它们拉到安全态,中间插一个_nop_(),最后只动一两个变量形成新的导通组合。这个顺序不是强迫症:如果把“开新管”写在“关旧管”之前,两个赋值之间就会有极短一段时间两组管子同时导通,母线经上下桥直接短路,电流上升速率只受寄生电感限制,几十纳秒就能到几百安。_nop_()在这里的作用就是给驱动电路留出关断延迟,等旧管真正关透了再开新管。
必须说清楚的是,VT、UT、WT、VB、UB、WB这六个宏在这段代码里没有给出定义,1 是高有效还是低有效取决于前级驱动有没有反相。所以读这段代码时,第一个动作应该是把驱动原理图和宏定义对齐,而不是照抄分支体的赋值。这一点放在后面的避坑章展开。
还有一个细节值得留意:case 2:和case 7:共用同一个分支,分支里WB被连续赋值两次——WB = 1;然后_nop_();之后又写WB = 0;。前一次赋值被后一次覆盖,这个状态实际的输出跟注释里的“V4,V5”对不上,大概率是复制粘贴留下的笔误。这类瑕疵在能跑的代码里很常见,因为电机转起来了、没炸,就没人回头查。
3.3 无效状态与三种特殊输出态
正常换相之外,Phase_Change()还处理三类特殊情况,靠的是最外层的if优先级:
第一类是EABS_Flag置位且不在助力状态,代码先把三个下桥写一遍,再_nop_(),然后把三个上桥写一遍。这不是换相,是把三相绕组按电刹车/滑行充电的需要接到固定电平上,让反电动势产生的电流有回路。
第二类是PWM_Duty_min非零,也就是过流触发后的封锁态:UT=0;VT=0;WT=0; UB=1;VB=1;WB=1;,注释写得很直白——“电机停转”。它是把驱动锁死在一个固定组合上,不做任何换相,直到过流标志被清掉。
第三类是case 9和default,全关输出。但注意H_State是三个引脚拼出来的 3 位数,最大只到 7,case 9永远进不去,是一段死代码。常见的实现思路是把“霍尔全 0 或全 1”这类异常组合单独处理,这里大概是历史遗留分支,不影响运行,但会误导读代码的人。
占空比和换相是两件事:换相决定“通哪两相”,PWM_ADJ()算出的PWM_Duty_Max决定“通多大力”。PWMCON1=0xC7打开 PWM 电路、三路反相输出,PWMCON3=0xF0配置输出引脚,换相表只负责相序,力量全交给 PWM 占空比。
4. 保护阈值:0xB48 和 0x9B0 背后的窗口长度约定
main()里那一串初始化函数,参数看起来像天书:Current_Lim(0xB48)、LowVoltage_Lim(0x9B0)、Speed_LimSW(0x01)。这些数字不是 ADC 原始值,而是“窗口内 N 个样本之和”。搞不清这一点,改保护阈值必然翻车。
4.1 限流阈值是 32 个样本之和,不是单点采样值
PWM_ADJ()里判断限流的条件写得很直白:if (Current_SUM < Current_Max)。Current_SUM是 32 点环形缓冲的和,Current_Max是Current_Lim()传进来的0xB48。所以单个样本的等效阈值是0xB48 / 32 ≈ 90。
对着注释里的标定就更清楚了:0xB00对应约 2.6A,0xB80对应约 3.8A。两个阈值相差0x80,电流相差 1.2A,于是每0x80个单位对应 1.2A,单位刻度约 15mA。0xB48落在两者之间,比 2.6A 高0x48/0x80个刻度,约 3.3A。欠压那边同理:注释写“电池电压 47.9V 时 ADC 采样值为 0xB6 ==> 0xB60”,而实际传入的是0x9B0,因为Voltage_Buffer有 16 个点,16 × 0x9B = 0x9B0,对应推算的 41V。
这个约定意味着:改窗口长度必须同步改阈值。如果把电流缓冲从 32 点改成 16 点,滤波变快,但Current_Max得跟着砍半,否则限流点直接翻倍,功率管和电池都危险。换电池组、改分压电阻同理,LowVoltage_Lim的值要按新的分压比重新算,注释里那套“已知一个点,线性外推”的方法就是干这个用的。
4.2 过流走外部中断,欠压走软件比较:两条路径的分工
值得问一句:既然 ADC 已经在采电流了,为什么过流还要单独用外部中断?
因为采样有节拍。电流通道 100us 采一次,最坏情况下从电流超限到软件发现,要等将近一个节拍;而母线上短路这种事,100us 足够把电流推到毁灭性水平。所以过流走了模拟比较器 → 外部中断 1 这条路,响应是微秒级,与采样节拍完全解耦。
void INT1_ISR() interrupt 2 { CurrentOver_Count++; if (CurrentOver_Count >= 5) // 连续 5 次才认账 { PWM_Duty_min = 1; // 封锁输出 CurrentOver_Count = 0; } }IT1 = 0配置的是电平触发,配合CurrentOver_Count >= 5做防抖。这个组合有取舍:电平触发意味着只要比较器还输出有效电平,中断就会被反复进入,计数很快到 5,封锁几乎立刻生效;代价是比较器输出必须尽快撤销,否则中断会持续占用 CPU。如果换成边沿触发,抗噪能力更好,但单次毛刺也能触发一次计数,需要更长的防抖窗口。代码选了响应速度优先,符合过流保护“宁可误封不可漏封”的取向。
欠压完全相反:电池电压变化慢,被大电容和电机负载双重平滑,没有任何瞬时风险,所以老老实实挂在软件里,Volt_Low()每 100us 用Voltage_SUM跟门槛比一次,响应慢几十毫秒也无所谓。
4.3 堵转判据:定时器 2 捕获与溢出的赛跑
转子不转,霍尔就不跳变,捕获中断不来,代码就没有换相依据。判断这件事的办法是让捕获和溢出比赛:
void Timer2_ISR() interrupt 13 using 2 // 捕获中断:霍尔跳变了 { Motor_Speed = TH2; // 两次跳变之间的计数值,越小越快 TH2 = 0; TL2 = 0; H_Sample(); Phase_Change(); } void T2_ISR() interrupt 8 // 溢出中断:等太久没跳变 { TF2 = 0; Motor_Speed = 0x50; // 强制按极低速处理 Block_Detect(); // 堵转保护 }CAPCON0=0xA8打开捕获模式,T2MOD=0xF0、RCAP2L/H=0把重装值设成 0,定时器 2 从零开始自由计数。霍尔每跳一次,捕获中断把当前计数值读进Motor_Speed然后清零重来,于是Motor_Speed直接就是换相周期的倒数——数值越小转得越快。PWM_ADJ()里那些Motor_Speed < 0x010的判断就是拿它当速度反馈用。
如果转子卡住,霍尔长时间不跳,定时器 2 一路数到溢出,T2_ISR立刻把Motor_Speed拍成0x50(相当于告诉上层“现在极慢”),再调Block_Detect()。注意0x50也正好是Init()里Motor_Speed的初值,用同一个魔数把“上电未知”和“堵转”归到同一档处理,省掉一个状态位。
用捕获而不是软件轮询计数,是因为换相周期在高速时可能只有几百微秒,主循环轮询的精度根本跟不上;捕获是硬件在事件发生瞬间锁存计数值,误差只有中断响应延迟那几十个机器周期。
5. 避坑排查:这五处最容易让人返工
5.1 功率级与换相:三条会让你炸管或抖动的坑
现象一:照着换相表接线,上电瞬间功率管直通。原因在于VT/UT/WT/VB/UB/WB这六个宏定义没有出现在片段里,1 到底代表“导通”还是“关断”,完全取决于前级驱动是 NPN 直驱、专用栅极驱动还是光耦反相。更麻烦的是同一个 case 分支里,对这六个变量既写 0 又写 1,如果拿不到定义,光看分支体根本推不出最终哪个管子是通的。解决方式是先把宏定义和驱动原理图对齐,用限流电源空载上电,示波器逐路看六根驱动线的电平,把真值表倒推出来再改代码。
现象二:case 2/7共用分支下电机顿一下、电流尖峰。原因是那个分支里WB被连写两次,后一次覆盖前一次,实际输出组合跟注释的霍尔状态对不上,换相时序错开一个电角度。解决方式是把这个分支拆成两个独立 case,按各自状态单独写输出,改完用霍尔状态切换的时刻和相电流波形对照验证。
现象三:高温或大电流下偶发直通,室温复现不了。原因是Phase_Change()里靠_nop_()撑出来的关断窗口太短。一个_nop_()就是一个机器周期,在 1/12 分频下具体多长取决于外部晶振,而功率管的关断延迟随温度明显变长,冷态够用的窗口热态就不够了。解决方式是把死区交给硬件——专用栅极驱动自带的死区时间,或者利用 PWM 模块的互补输出死区配置,软件里的_nop_()只能当最后一道心理安慰。
5.2 采样与阈值:两条让保护误动的坑
现象四:上电头几百毫秒欠压保护误触发,或者电机一启动就限流。原因是Init()把三个缓冲区和对应的 SUM 全部清成了 0,而真实 ADC 值不可能是 0。窗口填满之前,Voltage_SUM明显低于稳态值,Current_SUM同理,于是保护逻辑拿着一个偏小的和去跟阈值比,直接判定欠压;限流那边反过来,一旦某个瞬时值进来,和还没稳定就容易越界。解决方式有两种:上电后先空跑 N 个采样周期再放开保护,或者把缓冲区初值预填成第一次采样值而不是 0。
现象五:改了电池组,欠压保护点偏移得离谱。原因是阈值是“N 个样本之和”这个约定没被继承下来,有人按单点 ADC 值去改LowVoltage_Lim,忘了乘窗口长度 16。另一种情况是分压电阻换了但阈值没重算。解决方式是照注释里那套标定流程重走一遍:先用万用表量实际电池电压、同时读出 ADC 原始值,得到一个点;再按线性关系推算目标电压对应的采样值,最后乘上窗口长度写进参数。巡航和限速那几个参数也一样,Speed_LimSW的注释写的是“数值越小速度越大”,跟直觉相反,改之前先在台架上试。
顺带一个可读性问题:P02这个引脚被复用了三种语义。Init()里它是相位检测循环的门控条件,T0 中断里它是刹车判断(if (P02 == 0) Brake_Setting()),PWM_ADJ()里它又变成转把有效的使能条件(else if (P02 == 1))。一个引脚三种含义,靠上下文区分,读代码时极易看错。真要移植,第一件事是给它起三个不同名字的宏,把语义拆开。
6. 设计取舍:用确定性和整数换来的可维护性代价
这份代码从头到尾贯彻一个原则:所有时间相关的东西都锚在 100us 上,所有数值运算都用整数,所有实时逻辑都在中断里。好处是行为可预测——采样率、保护延时、换相时刻全是可以算出来的确定值,没有动态内存、没有浮点、没有优先级反转,跑在几块钱的单片机上也不会出现玄学问题。
代价同样明显,而且都集中在可维护性上:
| 设计选择 | 换来的好处 | 付出的代价 |
|---|---|---|
全部整数运算,>>1代替除法 | 无浮点库,代码体积小,周期确定 | 阈值是“样本和”,改参数必须记住窗口长度 |
| 功能全部挂在 100us 节拍 | 不需要多定时器,调度简单 | 中断执行预算紧张,加功能容易撑爆周期 |
| 换相表硬编码 switch | 执行快,路径唯一 | 六个桥臂宏定义缺失,可移植性几乎为零 |
| 过流走硬件中断,欠压走软件 | 各自匹配物理时间尺度 | 两条路径的防抖参数需要分别调 |
| 主循环空转 | 实时性完全由中断保证 | 任何新增的低优先级逻辑都没地方放 |
想验证这套设计到底靠不靠谱,不用读完全部代码,做四件事就够:中断入口翻转 IO 量中断耗时;可调电源从 48V 缓慢往下扫,记录欠压保护实际动作点和理论值的偏差;堵转台架上卡住转子,量从卡住到封锁输出的时间;示波器同时看霍尔信号和相电流,确认换相点和电流过零点对得上。这四项过了,剩下的大多是参数微调。
我自己的习惯是,拿到任何一份“能跑但说不清”的控制器代码,先不看功能函数,先把中断向量表和定时器配置抄出来,画出时间轴和优先级,再回去看业务逻辑。有一次就是跳过了这步直接改限流阈值,忘了它是 32 个样本之和,结果限流点被抬高了三倍多,台架上跑了不到两分钟功率管就烫得握不住——从那以后我每次动保护参数前,都强制把窗口长度、标定点、分压比这三样写在一张纸上再动手。这份代码的价值不在于它能直接抄进新产品,而在于它把一个真实控制器里“时间怎么切、采样怎么省、保护怎么分层”的取舍完整摊开给你看,顺着这些取舍往下读,比看十篇原理概述都管用。希望帮到你。
本文还有配套的精品资源,点击获取