news 2026/10/24 4:52:32

电动车控制器C语言源码解析:霍尔采样与PWM调速实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
电动车控制器C语言源码解析:霍尔采样与PWM调速实战

简介:这份代码概要面向电动车控制领域初学者与初级C语言学习者,围绕W79E83X系列微控制器,完整展示了电动车控制系统的核心C语言实现,涵盖电机相位控制、霍尔信号采样、助力与定速巡航、过流/欠压保护、软硬件限速等多类功能模块。包体为1个doc文档(约97KB),纯代码与注释文本便于逐行研读和复制调试,目前已有1128人学习浏览。文档对每一段关键代码附有详细参数说明,例如PWM占空比与助力力量、欠压保护ADC阈值对应电池电压、限速档位与速度值映射等,能帮助读者在真实硬件逻辑中理解嵌入式C语言的寄存器操作、中断初始化、I/O配置与主循环调度方式。整体代码框架清晰、注释完整,既可作为电动车控制器程序的入门参考,也可作为单片机嵌入式开发的实战范例。

1. 电动车控制器C语言源码:一份能跑的通电逻辑,也是一本嵌入式C的活教材

这份源码是某款电动车控制器的主控程序,芯片用的某厂商 W79E83X 系列,代码里几乎没留水分——从霍尔采样、相位换向、PWM 调速,到过流保护、欠压保护、助力、定速巡航、电刹车,全部摊开在主循环和中断里。对刚入行的 C 语言学习者,它比教科书上那些链表和排序实用得多,因为你能看到unsigned char怎么和电机电流打交道,能看到中断优先级怎么影响一辆车的安全性。对已经在做电机控制的工程师,这份代码的价值在于那些参数注释和采样时序,你可以直接对照自己的方案找差异。

整份代码读下来,最反直觉的一点是:核心控制逻辑几乎全在中断里,main里那个while(1)反而什么都没干。这也是大多数嵌入式控制的常态——主循环只是兜底,真正的动作都在时间和事件驱动的缝隙里完成。理解了这一点,这份源码就算读懂了一半。

2. 从 main 看懂控制器骨架:初始化顺序与功能开关

拿到一份控制器源码,先别急着看 PWM 和霍尔,第一步一定是从main函数捋清楚系统是怎么启动的。这段代码的main很典型:先初始化,再设置一堆功能参数,最后进入空转循环。整个骨架可以用一句话概括——所有功能都是通过带参数的函数调用来配置的,配置完之后,剩下的交给中断。

void main(void) { Init(); // 系统初始化:PWM、定时器、ADC、中断 Init_IO(); // 端口模式配置:推挽、开漏、高阻输入 H_Sample(); // 霍尔信号采样,取初始转子位置 Phase_Change(); // 根据霍尔状态执行相位变换 AutoHelpEN(1, 0x1AA, 200); // 助力功能 Keep_SpeedEN(1, 0x20, 6); // 定速巡航 Current_Lim(0xB48); // 过流保护阈值 LowVoltage_Lim(0x9B0); // 欠压保护阈值 EABS_Set(1, 1); // 滑行充电 + 电刹车 Speed_LimHW(0, 0, 0, 1); // 硬件限速:40km/h档 Speed_LimSW(0x01); // 软件限速 while(1) { _nop_(); } }

这里有几个值得新手注意的细节。Init()和Init_IO()是分开的,前者管的是 PWM 模块、定时器、ADC 中断这些片上外设,后者只做端口模式配置,这样分层的习惯在工程里很常见——如果哪天换 PCB 改了一个桥臂的 GPIO,你只需要动Init_IO,不用翻中断处理函数。H_Sample()在main里先执行一次是有讲究的,它要做的事情是拿到上电瞬间的霍尔状态,确保Phase_Change第一次执行时不会因为状态未知而乱输出。

参数设置阶段比较像在填一张配置表。以Current_Lim(0xB48)为例,注释里写了 0xB00 对应约 2.6A,0xB80 对应约 3.8A,这其实是在告诉你一个线性换算关系:ADC 采样值每增加 0x80,电流上限约增加 1.2A。你可以按这个斜率推出不同限流档位对应的数值,不用盲试。

Speed_LimHW(0,0,0,1)的四个参数是互斥的,只能有一个为 1,分别对应 15、20、30、40km/h 四档。这里的"硬件"不是说有独立的硬件比较器,而是说这个限速是直接限制在 PWM 输出层面的。Speed_LimSW(0x01)则是软件层再拦一道,数值范围 0x00~0x20,数值越小速度越快。

实际调试时你会发现,这些配置函数大多只是往全局变量里写值,真正的判断逻辑在中断函数里。比如AutoHelpEN设置的助力强度0x1AA,会在T0M1_ISR里通过AutoHelp()函数被读取,参与 PWM 占空比计算。理解了这个调用关系,你就明白为什么改参数要停在main里改——这些函数只在启动时执行一次。

3. 把霍尔信号变成通电逻辑:H_Sample 与 Phase_Change 的相位表

霍尔传感器在无刷直流电机里的作用,是告诉控制器转子现在转到哪了。控制器拿到位置信息后,需要决定上下桥臂哪几个管子导通,这就是相位变换。这份源码把这件事分成了两步:H_Sample负责采样并去抖,Phase_Change负责查表输出。

3.1 霍尔采样:连续读两次,直到状态稳定

霍尔信号是电平信号,理论上读到就是稳定的,但实际电机运行中会有换相噪声和电磁干扰,所以代码里做了一个非常朴素的去抖——读到两个完全相同的状态才算数。

void H_Sample(void) { CAPCON1 &= 0xF8; // 清除捕获模式配置 H1 = P12; // 霍尔 A 相接 P1.2 H2 = P07; // 霍尔 B 相接 P0.7 H3 = P20; // 霍尔 C 相接 P2.0 do { State1 = H1 << 2; // 将 H1 移到 bit2 State1 += H2 << 1; // H2 移到 bit1 State1 += H3; // H3 留在 bit0 _nop_(); _nop_(); // 稍微延时,等电平稳定 State2 = H1 << 2; State2 += H2 << 1; State2 += H3; } while(State1 != State2); // 两次采样不一致就重来 H_State = State1; // 更新全局霍尔状态 }

代码把三个霍尔信号拼成了一个三位的状态值。H1<<2意思是把 P1.2 的电平放到结果变量的第 2 位,H2<<1放到第 1 位,H3放到第 0 位。这样霍尔状态就被编码成一个 0~7 的数字,比如H1=1, H2=1, H3=0组合出来是110二进制,即十进制的 6。这个编码方式在Phase_Change的switch里直接当case用。

死循环等待去抖有一个潜在风险:如果电机停转时霍尔恰好处于电平翻转临界点,这个do...while可能会卡很久,影响中断响应。实际产品里一般会加超时退出,但这仍代表了一个学习上的重要经验——读霍尔不能只读一次。

3.2 相位变换:六步换向的开关逻辑

无刷直流电机的经典控制方式是六步换向,每一步对应一种霍尔状态,控制器输出一组特定的桥臂通断组合。看代码里的switch,它处理了case 6/2/7/3/1/5/0/4等状态,每个 case 对应一组开关动作。

void Phase_Change(void) { if(EABS_Flag) // 电刹车标志 { if(!AutoHelp_Flag) { UB = 1; VB = 1; WB = 1; // 下桥臂全开 _nop_(); UT = 1; VT = 1; WT = 1; // 上桥臂全开 } } else if(PWM_Duty_min) // 停转标志 { UT=0; VT=0; WT=0; UB=1; VB=1; WB=1; // 下桥臂全开,上桥臂全关 } else { switch(H_State) { case 6: // 110,V3/V4 导通 VT=0; UT=0; VB=1; WB=1; _nop_(); WT=1; UB=0; break; case 2: // 010,V4/V5 导通 case 7: UT=0; WB=0; VB=1; WB=1; _nop_(); VT=1; UB=0; break; // ... 其余状态类似 case 9: UT=0; VT=0; WT=0; UB=1; VB=1; WB=1; break; default: break; } } }

这里UT/VT/WT是上桥臂的三个管子,UB/VB/WB是下桥臂的三个管子。以case 6为例,霍尔状态是110,此时应该让 V 相上桥臂关闭、W 相上桥臂导通,同时 U 相下桥臂关闭、V 相和 W 相下桥臂导通。电流从 W 相上桥臂进,从 V 相下桥臂出,形成一条通电回路。

每个 case 中间都加了一个_nop_(),这是为了给桥臂开关留出极短的空闲时间,防止上下桥臂直通——直通意味着电池正负极短路,瞬间大电流会炸管子。不同硬件的开关速度不一样,如果你的驱动电路用的是慢速光耦,这个延时可能不够,需要自己测量死区时间。

case 9是一个特殊状态,对应默认情况下的全关断。实际电机运行中霍尔状态不太可能出现 9(因为三根霍尔线最多编码 0~7),这个 case 更像是一个兜底保护,防止意外值导致错误输出。

3.3 相位检测与上电自检:凭什么敢直接转

代码里还有Phase_Detect()函数,它的结构和Phase_Change很像,但只控制下桥臂,不动上桥臂。这个函数在上电时执行一次,配合while(P02==0)的等待条件,实现了一个简单的上电自检逻辑。

while(P02==0) // 如果刹车信号有效,就停在原地 { H_Sample(); // 不断重新采样霍尔 Phase_Detect(); // 只开下桥臂,做预通电 }

这段代码的含义是:如果刹车开关没释放(P0.2 为低电平),控制器不会进入正常换相流程,而是只对下桥臂做预通电,等待刹车释放。这比直接启动安全得多——很多飞车事故就是上电瞬间刹车没拉住,控制器直接按错误相位输出导致电机猛转。

4. PWM 调速与限流:转把电压到占空比的换算链路

电动车的油门(转把)本质上是一个霍尔旋转把手,输出 0.8V~3.5V 左右的电压。控制器的任务是把这路电压映射成 PWM 占空比,同时还要保证电流不超过设定值。源码里这一整套逻辑都集中在PWM_ADJ()函数里,配合T0M1_ISR的 ADC 通道轮询,形成了一个完整的采样-计算-输出闭环。

4.1 ADC 采样时序:为什么电流、速度、电压要轮流采样

看中断处理函数,它每 100us 触发一次,每次进入只切换一个采样通道。这里的采样策略很有意思——不是三个通道同时采,而是分时复用 ADC 模块。

void T0M1_ISR(void) interrupt 1 { Current_REQ = 1; // 先请求电流采样 Speed_REQ = 0; Voltage_REQ = 0; ADCCON = 2; // 切换到电流采样通道 Count_Speed++; if(Count_Speed > 5) // 每 6 次中断切换一次 { ADCCON = 4; // 切换速度采样通道 Current_REQ = 0; Speed_REQ = 1; Count_Speed = 0; Count_Voltage++; if(Count_Voltage > 5) { ADCCON = 3; // 切换电压采样通道 Speed_REQ = 0; Voltage_REQ = 1; Count_Voltage = 0; KS_Finish(); // 巡航控制检查点 } } // 下面是周期性功能调用 if(AH_Count >= 100) { AutoHelp(); AH_Count = 0; } if(KS_CNT >= 3000) { KS_CNT = 0; Keep_Speed(); } Volt_Low(); // 欠压保护 if(P02==0) Brake_Setting(); PWM_ADJ(); // 每次中断都调整 PWM }

从时序可以看出,电流通道 6 次采样、速度通道 6 次采样、电压通道 6 次采样,然后循环。由于每次中断是 100us,所以电流约 600us 更新一次,电压约 3.6ms 更新一次。ADC 每次采样完成会触发中断ADC_ISR,在中断里把采样值存入各自的环形缓冲区。

这种分时采样显著降低了 ADC 模块的负担,也避免了多通道同时采样时模拟多路开关的串扰问题。代价是通道切换之间存在相位延迟,但对于电流环和速度环这种低频控制场景,几百微秒的延迟完全够用。

4.2 环形缓冲区:滑动平均滤波的轻量实现

ADC 的原始值不能直接拿来控制电机——电磁噪声会让采样值在瞬间跳变,直接使用会导致 PWM 抖动,电机噪音大还可能引起机械共振。源码用环形缓冲区做了滑动平均滤波。

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; // 环形回绕 } // Speed_REQ 和 Voltage_REQ 同理 }

这里的逻辑核心是Current_SUM——它始终保存着环形缓冲区所有元素的总和。当新数据进来时,先减去最旧的数据,再加上新数据,Current_SUM就自动更新了。读取平均值时只需要Current_SUM >> 5(除以 32)即可,不需要遍历数组。

不同通道的缓冲区长度不同:电流 32 个点,速度 14 个点,电压 16 个点。缓冲区越长,滤波越平滑,但动态响应越慢。电流通道选 32 点是因为限流保护对平滑度要求高,而速度通道选 14 点是因为要兼顾转把的响应速度。

4.3 PWM 占空比计算:从转把电压到 0x06E8

核心的换算逻辑在PWM_ADJ()里:

void PWM_ADJ(void) { if(Current_SUM < Current_Max) // 限流判断 { if(Speed_SUM < Speed_Low) // 转把没拧 { // 停转逻辑:判断电机转速、刹车状态、滑行充电使能 if(Motor_Speed < 0x010) { if(P02 == 1) { if(EABS_SlipEN) { EABS_Flag = 1; if(PWM_Duty_Max < 0x55) PWM_Duty_Max = 0x055; // 滑行充电 } // ... 其他情况强制停转 } } } else if(P02 == 1) // 转把有电压且未刹车 { PWM_Duty_Max = Speed_SUM - Speed_Low; // 转把电压减去死区 EABS_Flag = 0; if(PWM_Duty_Max > 0x06F0) PWM_Duty_Max = 0x06E8; // 限幅 PWM_Duty_Max = PWM_Duty_Max >> 1; // 右移一位,缩放到 PWM 寄存器范围 } } }

Speed_Low是转把死区电压,即0x0500。转把输出低于这个值,控制器认为你没拧油门,电机不转;高于这个值,差值就是有效的油门深度。PWM_Duty_Max是 PWM 的最大占空比寄存器值,先减去死区,再限幅到0x06E8,最后右移一位。右移是因为 PWM 寄存器的有效位宽和 ADC 采样位数可能不一致,这种移位调整在嵌入式代码里非常常见。

需要特别注意的是限流分支:if(Current_SUM < Current_Max)成立才进入正常调速,如果电流超过了Current_Max,整个if块不执行,PWM_Duty_Max维持原值或由其他逻辑置零——这是限流保护的粗粒度版本,真正的细粒度限流在电机电流接近上限时是通过中断里的CurrentOver_Count累积来处理的。

4.4 定时器配置:为什么 PWM 频率必须和电机匹配

源码中 PWM 相关的初始化在主函数之外的Init()里:

PWMPH = 0x03; PWMPL = 0x55; // PWM 周期寄存器 PWM0H = 0x00; PWM0L = 0x00; // PWM0 占空比初始为 0 PWMCON1 = 0xC7; // 使能 PWM,三相反相输出

PWM 周期约为0x0355个时钟周期,反相输出模式意味着上桥臂和下桥臂的 PWM 信号互补,避免直通。这里的频率选择不是随意定的——太低的 PWM 频率会让电机发出明显的啸叫,太高则开关损耗变大。对于电动车常见的低速无刷电机,15kHz~20kHz 是常用区间,超出人耳听觉范围,噪音会小很多。

5. 保护机制实测拆解:过流、欠压、堵转的三个避坑提醒

电动车控制器最怕的就是炸管子和烧电机,所以保护逻辑写得好不好,直接决定这个方案的可靠性。源码里保护逻辑分布在中断和主循环里,各有分工。这一章我挑三个在实际调试中常见的问题讲清楚。

5.1 过流保护:计数器防抖比阈值判断更重要

外部中断INT1_ISR处理过流信号。硬件比较器检测到电流超过设定值,会拉低某个引脚,触发外部中断。

void INT1_ISR() interrupt 2 { CurrentOver_Count++; if(CurrentOver_Count >= 5) // 连续 5 次才确认过流 { PWM_Duty_min = 1; // 停转标志 CurrentOver_Count = 0; } }

这里有个非常典型的工程经验:过流信号不能采到一次就立刻动作,因为启动瞬间的浪涌电流、开关瞬间的尖峰都可能导致误触发。代码里CurrentOver_Count会累计,连续 5 次中断都检测到过流,才把PWM_Duty_min置 1,让电机停转。这就是"防抖"的思想——不是判断一个点,而是判断一个趋势。

实际操作时有一个坑:外部中断的触发方式,代码里IT1=0用了电平触发,这意味着只要过流信号一直为低,中断就会不停触发,CurrentOver_Count一直累加,没问题。但如果硬件比较器输出的是窄脉冲,电平触发可能会漏掉,此时应该改成边沿触发IT1=1。这一点在移植到其他芯片时要特别注意。

5.2 欠压保护:ADC 换算不是线性拍脑袋

LowVoltage_Lim(0x9B0)的参数换算在注释里写得很清楚:47.9V 对应 ADC 值 0xB60,41V 对应 0x9B0。这个关系的推导逻辑是:电池电压经过电阻分压后送入 ADC,ADC 参考电压固定,所以电压与 ADC 值是线性关系。

实际调试中真正的坑在于:电池电压在负载情况下会跌落。如果你拿万用表量电池空载电压是 48V,但一上负载掉到 42V,欠压保护可能提前触发。另一个问题是 ADC 参考电压的精度,如果参考电压有 5% 的误差,欠压点会偏移。用这个源码做产品时,最好在产线上做一次校准——标定两个点,把分压电阻的实际比例测出来,再换算成代码参数。

还有一个细节:欠压保护不能做得很激进,因为电池在回充(滑行充电)时电压会升高,如果保护阈值卡得太死,会出现骑行中频繁启停的割裂感。源码里每 3.6ms 才采样一次电压,配合 16 点滑动平均,已经有了足够的惯性,不会因为瞬时电压波动误动作。

5.3 堵转保护与定时器 2 溢出:你的霍尔线松了会怎样

代码中定时器 2 的溢出中断T2_ISR负责堵转保护:

void T2_ISR() interrupt 8 { TF2 = 0; Motor_Speed = 0x50; // 给一个默认转速值 Block_Detect(); // 堵转检测 }

电机正常运行时,霍尔信号会持续变化,每次变化都会触发定时器 2 的捕获中断,把Motor_Speed更新为两个霍尔边沿之间的时间间隔。如果电机被卡住或者霍尔线脱落,霍尔信号不再变化,定时器 2 溢出,Motor_Speed被赋默认值 0x50,然后走堵转检测逻辑。

这个设计聪明的地方在于:堵转检测不是专门等在那里判断"电机转没转",而是通过霍尔信号的自然节奏来触发。一旦霍尔信号消失,定时器溢出就接管了。但注意,Block_Detect的具体实现不在这份代码概要里,如果你实际用这个方案,需要自己补上"转速过低持续 N 秒就关断 PWM"的逻辑,否则电机堵转时 PWM 还会继续输出,时间长了会烧线圈。

5.4 我最想提醒的:别急着改参数,先把中断里那一堆标志搞清楚

拆这份源码最容易翻车的事情是——看着main里被注释掉的那几行调整函数,以为把它们放回while(1)里就能动态调速。实际上,AutoHelpEN、Keep_SpeedEN这些函数只在初始化阶段调用一次,它们设置的是全局变量标志,后续的运行逻辑全都在T0M1_ISR的AutoHelp()、Keep_Speed()里读这些标志。如果你在while(1)里反复调用这些函数去修改参数,不仅浪费 CPU,还可能在非预期时刻改变 PWM 占空比,造成电机抖动。

正确做法是:初始化时把参数设置好,然后在中断函数里根据实时状态去调整PWM_Duty_Max。如果真想实现动态调节,你应该改的是PWM_ADJ()里对Speed_SUM和Speed_Low的比较逻辑,而不是在while(1)里频繁调用配置函数。

6. 上手改这份源码:参数推算方法与验证顺序

我拿到这份代码时,第一件事不是读main,而是把所有带数值的参数全部抄在一张纸上,然后用已知的换算关系倒推了一遍。这个习惯帮我节省了大量调试时间。

以欠压保护为例,代码注释只给了两个点:47.9V -> 0xB60,41.0V -> 0x9B0。如果我想设定 45V 欠压,可以按线性比例推算:0xB60 - 0x9B0 = 0x1B0,对应电压差47.9 - 41 = 6.9V,所以单位电压对应的 ADC 值是0x1B0 / 6.9 ≈ 0x3E。45V 比 41V 高 4V,所以0x9B0 + 4 * 0x3E = 0xA48。这个值可以先用着,再拿到实车上微调。

验证顺序通常是这样:先调限流,再调欠压,最后调限速。限流如果调太大,电机启动时会猛冲,危害最大;欠压调太深则会把电池放空损坏电池。限速影响骑行体验,放到最后慢慢磨。

调限流时有个技巧——把Current_Lim的参数调小一点,听电机有没有明显的顿挫感。如果有,说明限流值太接近正常运行电流了,要放大;如果完全没有感觉,可以试着推车启动,看电机是否有力。过流保护的价值要在电机卡死时才能体现,可以用手捏住电机轴,轻轻加速到堵转状态,听控制器有没有"咔哒"一声关断输出。

专门说一下注释里那几行被注释掉的滑行充电参数。EABS_Set(1, 1)打开滑行充电后,下坡松油门时电机会变成发电机,把动能回馈到电池。这时PWM_Duty_Max会被限制在0x055,这意味着回充强度很弱。如果你想调大回充力度,可以改这个值,但要注意:回充太强会让整车有强烈的拖拽感,体验很差,而且在电池快满时继续充电会过充。我一般建议保持默认,除非客户明确要求。

验证代码是否正常工作的检查点可以放在这几个位置:观察Current_SUM是否稳定(正常应在小范围内波动),观察PWM_Duty_Max是否随转把电压线性变化,观察霍尔状态切换是否连续——不要出现从状态 1 直接跳到状态 4 的跳变。

从拆这份代码到现在,我带过几个刚入行的朋友读它,几乎每个人都会在环形缓冲区那里卡住,不理解Current_SUM为什么加减一次就算完。其实你只要在纸上画 32 个格子的循环队列,把头和尾标出来,模拟往里填 33 个数字,就明白它等价于一个永远保持最新 32 个点之和的滑动窗口。

从那以后,我每次拿到一份别人的源码,都会强制自己先做一件事:用笔把所有带注释的数值换算关系抄一遍,不放过任何一个0x开头的数字。因为对嵌入式控制器来说,每一个写进寄存器或比较器的常数,背后都是一条真实的电压、电流或速度边界。参数推算得越清楚,上电那一刻就越有底气,而不是靠改数试运气。希望这份拆解能帮你在自己的项目里少走几趟弯路。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 4:52:17

基于PCA9422与STM32的嵌入式电源管理与DVFS实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 4:52:09

STM32F302VC与PCA9422的完整电源管理方案实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 4:51:47

手写线程池模型:用互斥锁与条件变量实现并发访问控制

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 4:50:45

Android Studio新版生存指南:Gradle、JDK、SDK与模拟器深度适配

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/24 4:48:01

PCA9422+STM32L081CB超低功耗电源架构设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华