嵌入式C++教程实战之Linux下的单片机编程(8):推挽、开漏与PC13 —— LED点亮的硬件秘密

张开发
2026/4/9 11:14:03 15 分钟阅读

分享文章

嵌入式C++教程实战之Linux下的单片机编程(8):推挽、开漏与PC13 —— LED点亮的硬件秘密
嵌入式C教程实战之Linux下的单片机编程8推挽、开漏与PC13 —— LED点亮的硬件秘密仓库已经开源仍然在持续建设中喜欢的话点个⭐相关的链接如下https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeModernCPP静态网页直接阅览https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeModernCPP/上一篇我们把GPIO的四种模式从里到外翻了个底朝天内部结构图上的P-MOS和N-MOS也画明白了。但我们留了几个关键问题没展开推挽输出和开漏输出到底有什么区别为什么LED控制要选推挽还有那个Blue Pill板上的板载LED为什么是低电平点亮的这些问题的答案藏在硬件电路里不搞清楚的话代码写得再漂亮也只是空中楼阁。这一篇我们就来把这些硬件秘密一个一个拆开。前言从模式到选择上一篇的结尾我们提到GPIO有四种基本模式——输入浮空、输入上拉、输入下拉、模拟输入再加上输出方向的推挽和开漏组合起来一共八种配置。那个结构图上两个MOS管一上一下的布局现在应该还在你脑海里。但我们当时只是知道了这些模式的存在还没有深入讨论一个很实际的问题当你真正要驱动一个LED的时候推挽和开漏该选哪个这个问题看起来简单得不像话——LED嘛输出高电平亮、低电平灭推挽就行了。但如果你真的这么想那就掉进了两个坑里。第一个坑是Blue Pill板上的LED是低电平点亮的高电平亮这个直觉在这里恰恰是反的。第二个坑是如果你手滑选了开漏模式LED可能完全不亮或者暗得几乎看不见你还会以为代码写错了debug半天才发现是输出模式选错了。更微妙的是PC13这个引脚。它是Blue Pill板载LED连接的那个GPIO但这个引脚在STM32F103C8T6的内部设计中有一堆特殊限制——上下拉电阻不可用、驱动能力受限、速度也有限制。如果你不了解这些限制在配置GPIO的时候可能会传入一些逻辑上正确但硬件上无效的参数然后对着一个不亮的LED怀疑人生。所以我们现在要做的是把推挽输出和开漏输出的内部电路搞清楚把PC13的特殊限制弄明白再把Blue Pill板上的LED电路图摊开来分析。只有把这些硬件原理都理解透了你写出来的每一行GPIO配置代码才会有底气。推挽输出——LED的默认选择让我们先把推挽输出的内部电路画出来。STM32F103的每个GPIO引脚在输出模式下内部有两个MOSFET金属氧化物半导体场效应管一个P-MOS在上边一个N-MOS在下边形成所谓的图腾柱结构VDD (3.3V) | [P-MOS] ← 上管High-Side | ──────────── 输出引脚 Pin | [N-MOS] ← 下管Low-Side | VSS (GND)这个电路的工作原理其实很直觉。当输出数据寄存器ODR写入1的时候控制逻辑会让P-MOS导通、N-MOS关断。P-MOS导通之后VDD和输出引脚之间形成了一条低阻抗的通路引脚电压被推到接近VDD的3.3V——这就是输出高电平。反过来当ODR写入0的时候P-MOS关断、N-MOS导通输出引脚和VSS之间形成低阻抗通路引脚电压被拉到接近0V——这就是输出低电平。你会发现无论输出高还是低总有一个MOS管处于导通状态在VDD或VSS和输出引脚之间提供一条低阻抗的驱动通路。这就是推挽这个名字的来源——“推”Push是P-MOS把电流推向负载方向从VDD经过引脚流向外部“挽”Pull是N-MOS把电流从负载挽回来方向从外部经过引脚流向VSS。两个管子交替工作就像一个跷跷板的两端总是在主动驱动引脚的电平。这种双向主动驱动带来了两个关键优势。第一是驱动能力强——因为MOS管导通时的导通电阻很小典型值在几十欧姆的量级所以推挽输出可以提供或吸收相当可观的电流。STM32F103的GPIO在推挽模式下可以输出或吸收最多25mA的电流当然这是绝对最大值实际使用中要留余量。对于LED这种需要几毫安到十几毫安电流的负载来说推挽输出绑绑有余。第二是开关速度快。MOS管从关断到完全导通只需要很短的时间而且因为两个管子交替驱动输出信号的上升沿和下降沿都很陡峭。这对于高频信号比如SPI时钟、UART波特率来说至关重要因为如果边沿太慢信号在高电平和低电平之间徘徊的时间太长接收端可能会误判逻辑电平。现在回头看我们的代码。在device/led.hpp第13-15行中LED的构造函数是这样写的LED(){Base::setup(Base::Mode::OutputPP,Base::PullPush::NoPull,Base::Speed::Low);}这里的Mode::OutputPP就是在告诉HAL库“我要把这个引脚配置成推挽输出模式”。回头看device/gpio/gpio.hpp第25行这个枚举值对应的是HAL的GPIO_MODE_OUTPUT_PP常量。HAL库在收到这个配置后会去操作GPIOx_CRH或GPIOx_CRL寄存器把对应位设置成00通用推挽输出模式最大速度10MHz——这是Speed::Low对应的值。为什么LED控制一定要选推挽因为LED需要引脚输出一个确定的高电平或低电平来控制亮灭。推挽输出在两个方向上都是主动驱动的——输出高时P-MOS把引脚拉到3.3V输出低时N-MOS把引脚拉到0V。引脚上的电压是确定的、可控的LED两端的电压差也是确定的电流路径清晰明了。如果你选了开漏输出下面马上讲情况就完全不一样了。开漏输出——另一种选择开漏输出的内部电路和推挽有一个关键区别P-MOS上管被断开了只保留了N-MOS下管VDD (3.3V) | [外部上拉电阻] ← 必须由外部电路提供 | ──────────── 输出引脚 Pin | [N-MOS] ← 只有下管在工作 | VSS (GND)注意图中标注了必须由外部电路提供——这是理解开漏输出的关键。在开漏模式下芯片内部的P-MOS是不参与工作的引脚和VDD之间没有直接的驱动通路。这意味着当你让引脚输出高电平的时候芯片所做的全部动作就是关断N-MOS——然后引脚就浮空了High-Impedance状态既不被拉向VDD也不被拉向VSS它就那么飘着电压不确定。要让引脚真正变成高电平你需要在芯片外部加一个上拉电阻把引脚连接到VDD。当N-MOS关断时上拉电阻把引脚缓慢地拉向VDD当N-MOS导通时引脚被直接拉到VSS此时电流从VDD经过上拉电阻流入N-MOS到地。上拉电阻的阻值决定了上升沿的速度和静态功耗——电阻太小N-MOS导通时电流太大功耗高电阻太大上升沿太慢信号质量差。这是一个需要根据应用场景来权衡的参数。如果用开漏模式来驱动LED会发生什么情况取决于外部电路的设计。假设你的LED是经典的引脚串联电阻到VDD的接法高电平点亮那么当N-MOS关断输出高电平时引脚浮空如果没有外部上拉电阻LED的阳极可能达不到足够的电压来正向导通。结果就是LED要么完全不亮要么亮度极低取决于引脚浮空时的实际电压。而当你输出低电平时N-MOS导通引脚被拉到接近0VLED两端的电压差反而是最大的——这和推挽模式下的行为完全反过来了。⚠️踩坑预警如果你误选了开漏模式来驱动LEDLED可能完全不亮或者亮度极低。这是因为开漏输出高电平时实际上只是让引脚浮空了并没有主动驱动到3.3V。对于需要确定电平的LED控制推挽才是正确选择。这个错误在调试时特别难以发现因为你的代码逻辑完全正确——HAL_GPIO_WritePin()调用无误时序也对——但就是灯不亮。你会花大量时间去检查接线、检查时钟配置、检查HAL初始化最后发现只是Mode选错了。那开漏输出到底有什么用它的价值体现在几个特定场景。第一个是I2C总线。I2C协议要求多个设备共享同一条数据线SDA和时钟线SCL任何设备都可以把线拉低但不能主动把线拉高——线的高电平由总线上统一的_pull-up_电阻来提供。开漏输出完美匹配这个需求输出0时N-MOS导通把线拉低输出1时N-MOS关断让线通过上拉电阻回到高电平。如果某个设备用推挽输出了高电平而另一个设备同时想把线拉低就会造成短路可能烧坏芯片。第二个场景是线与Wired-AND逻辑。多个开漏输出连在一起共用一个上拉电阻只要任何一个输出低电平N-MOS导通整条线就是低电平。这种特性在多主机总线、中断共享线路中非常有用。第三个场景是电平转换——如果你的STM32工作在3.3V但需要和5V系统通信开漏输出加上拉到5V的上拉电阻就可以实现3.3V到5V的电平转换前提是引脚是5V容忍的STM32F103的大部分引脚都是。理解了推挽和开漏的本质区别之后你就知道为什么LED控制一定要选推挽了。LED需要引脚输出确定的高/低电平需要足够的驱动电流不需要线与逻辑也不需要电平转换。推挽输出在两个方向上都主动驱动是最简单、最可靠的选择。上拉和下拉电阻——推挽之下为何选NoPullGPIO引脚内部除了那两个用于输出驱动的MOS管之外还有可以软件配置的上下拉电阻。在device/gpio/gpio.hpp第39-43行中我们定义了三种选项enumclassPullPush:uint32_t{NoPullGPIO_NOPULL,PullUpGPIO_PULLUP,PullDownGPIO_PULLDOWN,};这三种配置的含义需要从引脚在没有外部驱动时的行为说起。当配置为NoPull无上下拉时引脚处于浮空状态。如果你把一个没有连接任何外部电路的GPIO引脚配置成输入模式并且选择NoPull然后用万用表测量它的电压你会发现读数在一个不确定的值附近跳动——它可能被周围环境的电磁干扰影响也可能被你手指靠近时的静电耦合改变。这就是所谓的浮空状态引脚电平不确定。但这对于输出模式来说不是问题。因为在推挽输出模式下引脚始终被P-MOS或N-MOS主动驱动——要么被拉到VDD要么被拉到VSS。上下拉电阻在输出模式下基本上是多余的因为MOS管的驱动能力远大于内部上下拉电阻内部上下拉电阻的典型值大约是40KΩ而MOS管导通时的等效电阻只有几十欧姆差了三个数量级。PullUp上拉配置会在引脚和VDD之间连接一个约40KΩ的内部电阻。这个电阻在引脚没有被外部信号驱动时会把引脚电平拉到高电平。最常见的应用场景是按钮输入按钮的一端接GPIO引脚另一端接地。按钮没按下时内部上拉电阻把引脚维持在VDD高电平按钮按下时引脚直接接地变成低电平。这样你就可以通过检测引脚电平的下降沿来判断按钮被按下了。PullDown下拉则反过来在引脚和VSS之间接一个约40KΩ的电阻让悬空的引脚默认为低电平。适合按钮另一端接VDD的场景——按钮没按下时引脚是低电平按下后变成高电平。回到我们的LED代码构造函数中传入的是PullPush::NoPull。原因很简单LED引脚被配置成了推挽输出模式P-MOS和N-MOS已经在主动驱动引脚电平了内部上下拉电阻在这里完全是个摆设。你加不加它引脚的输出行为都不会有任何改变。所以选NoPull是最干净的选择——不加多余的配置减少不必要的静态功耗虽然这个功耗微乎其微。但这里有一个更深层的原因和我们接下来要讲的PC13有关。先记住这个结论等一下你会明白为什么NoPull不仅仅是最干净的选择而是PC13上唯一合理的选择。PC13的特殊限制——一块有脾气的引脚到这里我们需要把话题聚焦到Blue Pill开发板上PC13这个具体的引脚。如果你翻过STM32F103C8T6的数据手册Reference Manual RM0008你会在GPIO章节找到一段不起眼但极其重要的注释大意是说PC13、PC14、PC15这三个引脚的供电方式和其他GPIO不同它们由芯片内部的备份域Backup Domain供电而不是由普通的VDD供电。这个设计决策背后有着明确的功能考量。PC13可以用作RTCReal-Time Clock的校准输出或者入侵检测Tamper Detection输出PC14和PC15可以用作LSELow Speed External低速外部晶振的振荡器引脚OSC32_IN和OSC32_OUT。这些功能都和RTC及备份寄存器相关属于芯片的备份域部分需要在主电源VDD断电后仍然能由VBAT电池供电继续工作。所以ST在设计芯片时把这三个引脚的供电划归到了备份域。这样做带来了一个直接后果这三个引脚的驱动能力受到严格限制。数据手册上明确写着PC13在输出模式下的最大电流只有3mA而不是普通GPIO的25mA而且只能工作在最低的速度等级2MHz。PC14和PC15的限制更严——它们的输出速度不能超过2MHz而且只能驱动极小的容性负载。如果把它们当普通GPIO来用驱动大电流负载可能会损坏芯片内部的备份域供电电路。更关键的是上下拉的问题。因为PC13/14/15的供电来自备份域而内部上下拉电阻连接的是主VDD域这两个电源域之间不能随便直连。所以ST在设计时这三个引脚的内部上下拉电阻要么不存在要么功能受限。具体来说在STM32F103上当PC13被配置为通用GPIO输出模式时内部上下拉功能是不可用的——你写入CRH寄存器的上下拉配置位会被硬件忽略。这意味着我们的LED代码中PullPush::NoPull不仅仅是一个干净的选择——它是PC13上唯一有效的选项。你传入PullUp或PullDownHAL库会忠实地把配置写进寄存器但硬件不会执行。这对于LED来说无关紧要因为推挽输出本身就在主动驱动不需要上下拉。但如果你以后想在PC13上做输入检测比如用它读一个按钮的状态你就必须外接上拉或下拉电阻——内部的那套在这里帮不了你。⚠️踩坑预警如果你计划在其他引脚上使用LED比如PA0或PB0那是可以启用上下拉的。但PC13/14/15不行。代码中的模板系统不会阻止你传入错误的配置——C编译器只检查类型不检查硬件兼容性。你完全可以写Base::setup(Base::Mode::OutputPP, Base::PullPush::PullUp, Base::Speed::High)编译通过没问题烧录也不会报错但PC13上的PullUp配置和高速度设置都不会生效。这就是为什么理解硬件原理很重要——编译器能帮你检查语法错误但检查不了硬件语义错误。还有一个和PC13相关的限制是速度。我们在代码中选了Speed::Low这对于LED来说当然足够了——1Hz的闪烁频率任何速度等级都能胜任。但即便你想选高速也没用PC13的输出速度上限就是2MHz超出这个限制的配置同样会被硬件忽略。所以Speed::Low既是合理的选择也是PC13上实际能用的最高配置Speed::Low在F103上对应2MHz和PC13的限制刚好匹配。Blue Pill板载LED电路——为什么低电平点亮现在到了最关键的部分。前面我们一直在讲GPIO的输出模式、上下拉、PC13的限制现在该把这些知识串起来分析Blue Pill开发板上PC13连接的那个LED到底是怎么工作的了。Blue Pill开发板的原理图上PC13和LED之间的连接方式是这样的VDD (3.3V) | [R 限流电阻约1KΩ] | [LED 正极 ← 负极] | PC13 (GPIO引脚)注意看这个电路LED的正极阳极通过限流电阻连接到VDD3.3VLED的负极阴极直接连接到PC13引脚。这和我们通常直觉中的引脚输出高电平→LED亮的接法正好相反。通常的接法是引脚连阳极、阴极接地输出高电平时有电流从引脚流向LED到地。而Blue Pill的接法是VDD连阳极、引脚连阴极形成了一种灌电流Sink Current的驱动方式。让我们分析两种状态下的电流路径当PC13输出低电平0V时VDD3.3V→限流电阻→LED正极→LED负极→PC130V。VDD和PC13之间有大约3.3V的电压差减去LED的正向导通压降红色LED大约1.8-2.2V剩余电压落在限流电阻上。假设LED压降2V那么限流电阻上的电压约为1.3V流过LED的电流约为1.3V/1KΩ 1.3mA。这个电流足以让LED发出可见光。所以低电平时LED点亮。当PC13输出高电平3.3V时VDD3.3V→限流电阻→LED正极→LED负极→PC133.3V。VDD和PC13之间几乎没有电压差两者都是3.3V没有电流流过LED。所以高电平时LED熄灭。这就是所谓的低电平有效Active Low——LED在引脚输出低电平时被点亮。这种设计在嵌入式开发板上非常常见原因有几个一是灌电流电流流入引脚通常比拉电流电流从引脚流出的驱动能力稍强二是很多MCU的上电默认状态引脚是高电平或高阻态用低电平有效可以避免上电瞬间LED闪烁。但对于初学者来说这个反直觉的设计往往是最让人困惑的地方。理解了这个电路之后再看我们代码中的ActiveLevel枚举和on()方法就完全豁然开朗了。在device/led.hpp第6行和第17-20行enumclassActiveLevel{Low,High};// ...voidon()const{Base::set_gpio_pin_state(LEVELActiveLevel::Low?Base::State::UnSet:Base::State::Set);}ActiveLevel::Low表示低电平为有效电平即LED在低电平时点亮。所以当LEVEL为ActiveLevel::Low时on()方法输出Base::State::UnSet——也就是低电平GPIO_PIN_RESET。off()方法则反过来输出Base::State::Set高电平GPIO_PIN_SET。然后在main.cpp第11行中我们实例化LED的时候device::LEDdevice::gpio::GpioPort::C,GPIO_PIN_13led;注意这里没有显式指定第三个模板参数ActiveLevel它的默认值是ActiveLevel::Low见device/led.hpp第8行的模板声明ActiveLevel LEVEL ActiveLevel::Low。这正好对应Blue Pill板上PC13 LED的低电平有效特性。如果你的LED接法是引脚→电阻→LED→地高电平点亮你只需要改模板参数device::LEDdevice::gpio::GpioPort::A,GPIO_PIN_0,device::ActiveLevel::Highled;这样on()就会输出高电平来点亮LED。模板系统把硬件差异抽象成编译期参数你不需要改任何逻辑代码只需要告诉模板这个LED是高电平有效还是低电平有效就行了。速度设置——是压摆率不是频率最后还有一个容易误解的配置项需要解释——GPIO的速度设置。在device/gpio/gpio.hpp第45-49行中定义了三档速度enumclassSpeed:uint32_t{LowGPIO_SPEED_FREQ_LOW,MediumGPIO_SPEED_FREQ_MEDIUM,HighGPIO_SPEED_FREQ_HIGH,};这三个名字可能会让人产生误解——速度听起来像是指引脚能以多快的频率切换高低电平。但实际上GPIO速度设置控制的是输出信号的压摆率Slew Rate也就是电压从低电平跳变到高电平或反过来时边沿的陡峭程度。压摆率高意味着电压上升/下降得快边沿陡峭压摆率低意味着电压上升/下降得慢边沿平缓。这和引脚的切换频率没有直接关系——你可以用低速设置以很高的频率切换引脚只是每次切换时边沿不那么陡峭而已。那为什么需要控制压摆率主要原因是EMI电磁干扰。信号的边沿越陡峭包含的高频谐波分量越多向外辐射的电磁干扰就越强。在高速信号线比如SPI时钟线、USB数据线上你需要陡峭的边沿来保证信号完整性所以选高速。但在LED这种低速场景下陡峭的边沿没有任何好处反而会增加不必要的EMI和功耗。所以选低速是最合理的。在STM32F103上三档速度设置对应的实际压摆率大概是Low对应2MHz带宽Medium对应10MHzHigh对应50MHz。这里的带宽是指输出信号能以多快的压摆率变化而不是说引脚只能以2MHz的频率翻转——实际翻转频率取决于你的软件循环速度。对于以1Hz频率闪烁的LED来说任何速度设置的效果都完全一样——人眼根本分辨不出电压边沿是1微秒还是10纳秒。选Speed::Low既减少了EMI也符合PC13引脚本身的2MHz速度限制是最合理的选择。如果你以后做SPI通信时钟频率可能高达18MHz或36MHz就需要用Medium或High来保证SCK信号的边沿足够陡峭否则从设备可能无法正确采样数据。但在LED这个场景下低速就够了别浪费那些不需要的带宽。收尾硬件原理到代码逻辑的闭环到这里LED点亮的硬件原理终于完全闭环了。我们从推挽输出的P-MOS/N-MOS双管结构讲到开漏输出的单管局限从上下拉电阻的原理讲到PC13的备份域限制从Blue Pill板载LED的灌电流电路讲到代码中ActiveLevel枚举的设计意图。现在你再回头看device/led.hpp那短短三十行代码每一行都有了明确的硬件依据——Mode::OutputPP对应推挽双管驱动PullPush::NoPull对应PC13的上下拉不可用以及推挽本身不需要上下拉Speed::Low对应PC13的2MHz上限和LED的低速需求ActiveLevel::Low对应Blue Pill的低电平有效电路。理解了这些之后你的开发流程就不再是无脑的复制粘贴了。当你需要在另一个引脚上接LED、接按钮、接I2C设备时你会知道该选什么输出模式、要不要上下拉、速度设多少。这些都是硬件原理赋予你的判断力而不仅仅是教程上这么写的。下一篇我们进入HAL库的世界。到现在为止我们一直在用自己的模板类封装GPIO操作但底层的HAL_GPIO_Init()和HAL_GPIO_WritePin()到底做了什么它们是怎么把我们的配置参数转化成寄存器操作的还有那个GPIOClock::enable_target_clock()——为什么GPIO需要先开时钟才能工作在回答这些问题之前我们需要先理解STM32的时钟树这是一张让无数新手望而生畏的大图。不过别担心我们一步一步来先把时钟使能这件事搞清楚——不开时钟GPIO就是一坨睡死的硅。相关阅读嵌入式C教程实战之Linux下的单片机编程7GPIO到底是什么 —— 通用输入输出的前世今生 - 相似度 100%入门 · 环境搭建 · 00 · Qt6 安装踩坑指南 - 相似度 80%现代Qt开发——0.1——如何在IDE中配置Qt环境 - 相似度 80%

更多文章