ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32嵌入式C++实战:从CubeMX到LED点灯全流程

STM32嵌入式C++实战:从CubeMX到LED点灯全流程 先回答你那句“看了三篇了一行都没让我写呢”。这话我收下了。前面四篇咱们聊了工具链、编译原理、内存模型、启动流程说白了就是没让你碰键盘之前先把“代码怎么变成机器码、变量放哪、芯片上电先跑谁”这些地基讲清楚。嵌入式C和写网页不一样条件没搞明白就开写后面八成是各种HardFault和undefined reference满天飞。今天这篇不绕了给你一条完整的实操链路从CubeMX生成工程、把C工程改成C工程、写一个正经的LED类、编译烧录到看着板子上的灯按你的节奏亮灭。全部围绕STM32展开用的还是嵌入式C那套思路你照着一步步来肯定能跑起来。1. 先把话说清楚前几篇为什么不写代码说实话我写前几篇的时候也挣扎过。嵌入式C入门最大的痛点不是语言本身而是“环境都没起来就对着代码发呆”。你说你学会了class和template结果连.cpp文件都没在工程里编译过链接的时候报一个undefined reference to HAL_GPIO_WritePin你根本不知道问题出在C和C的符号链接机制上。所以我刻意把启动文件、链接脚本、栈和堆这些“看着和写代码无关”的东西先铺了一遍。这一篇终于到了动手环节但也不是一上来就甩几百行代码给你。点灯这个经典实验本质上是让你把“芯片初始化→外设配置→业务逻辑→编译下载→看硬件反馈”这条闭环跑通。只要跑通了后面什么串口、定时器、I2C、USB都是在这条闭环上换外设而已。这一篇你要准备的硬件非常便宜软件全免费总共耗时预计两小时。我会默认你已经装好了STM32CubeMX和arm-none-eabi-gcc如果你还在用Keil也不影响理解只要把工程文件后缀处理方式对上就行。如果你还没装看完这一节再动手也来得及。2. 动手准备一块板子、一条线、一套工具链2.1 软件CubeMX GCC VS Code为什么不用Keil工具链这块我先说结论主路线用STM32CubeMX生成工程然后用GCC工具链在命令行或VS Code里编译烧录用ST-Link。这条路线的好处是每一步都看得见你能清楚看到“哪个文件参与编译、链接时加了什么库、地址是怎么分配的”。很多新手一上来就装KeilKeil当然也能做尤其MDK的调试器做得挺稳。但它最大的问题是把编译过程包得太严实你双击一下全给你干了报错的时候又一堆“Warning: #1-D: last line of file ends without a newline”这种让人摸不着头脑的东西。对于想认真学嵌入式C的人来说我建议至少先用MakefileGCC跑通一次体会一下“源文件→编译→链接→生成hex”的完整过程。等你心里有底了回头再用Keil会顺手很多因为你知道背后发生了什么。具体的组合是STM32CubeMX用来生成芯片初始化代码配置时钟和引脚。arm-none-eabi-gcc交叉编译工具链把代码编成ARM Cortex-M3能跑的机器码。GNU Make执行编译和链接流程的构建工具CubeMX会帮你生成Makefile。ST-Link工具或OpenOCD把生成的bin文件烧进芯片。VS Code可选一个看得舒服的编辑器配合C/C插件做代码高亮和跳转。如果你嫌命令行折腾也可以直接用STM32CubeIDE它内置了上面所有东西CubeMX配置完直接编译下载一条龙。但我是建议第一遍还是手动拆开来做这样遇到问题能分清是编译期、链接期还是下载期的事。2.2 硬件F103C8T6蓝板加ST-Link便宜到可以随便折腾开发板选的是最常见的STM32F103C8T6蓝色板淘宝上几十块钱一大把。这板子虽然便宜但该有的都有一个板载LED接在PC13、一个用户按键、一个USB转串口芯片通常是CH340、还引出所有GPIO。F103C8T6是64KB Flash、20KB RAMCortex-M3核跑72MHz作为学习板完全够用而且网上资料多到溢出。烧录器我建议直接买ST-Link V2十几块钱那种山寨版也能用。为什么不用板载的串口下载因为串口ISP下载只能把程序灌进去不能在线调试。你后面写复杂逻辑总要打断点看变量ST-Link的SWD接口既能下载又能调试一步到位。接线很简单ST-Link的3V3接板子3.3V、SWDIO接SWDIO、SWCLK接SWCLK、GND接GND四根线完事。要注意别把3.3V和GND接反反了芯片当场冒烟别问我怎么知道的。顺带说一句“芯片第一脚怎么确认”这个新手高频问题。STM32封装上有一个圆形小坑或斜角标记对着这个小坑看丝印坑旁边那个脚就是1脚然后逆时针数过去就是2、3、4……蓝色板上已经帮你引出了但你自己画板或者买裸芯片时会用到这个常识。3. 用 CubeMX 生成工程骨架点灯前的 10 分钟3.1 选芯片、配时钟为什么非得上72MHz打开STM32CubeMXNew Project在芯片搜索框输入STM32F103C8T6双击选中。这一步会弹出一个初始化弹窗问你是否要初始化所有外设选No就行我们手动配。进到主界面后先看左边Pinout Configuration面板。我们第一步要配RCC也就是时钟源。在System Core里找到RCC把High Speed ClockHSE改成Crystal/Ceramic Resonator。这一步相当于告诉芯片我有一个外部8MHz晶振请用它做系统时钟源。然后切到Clock Configuration页这一页是很多新手直接忽略但其实是核心的东西。F103的默认时钟是内部8MHz的HSI如果不配PLLCPU就跑8MHz。8MHz下点灯也能闪但后面你配串口波特率、定时器定时时长时会抓狂——因为所有外设时钟都是从系统时钟分频出去的基础频率不对算出来的全是小数。正确配法是HSE选8MHzPLL Source选HSEPLL倍频设x9这样得到72MHz刚好到F103C8T6的极限。APB1预分频设/2让APB1总线跑36MHz因为USART2/3、TIM2~7这些外设挂在APB1上最高只能36MHzAPB2不分频保持72MHzTIM1/8和USART1挂在它上面。这一步配完之后CubeMX会给你一个绿色的对勾表示所有时钟都在允许范围。3.2 配置GPIO与生成工程PC13这颗灯大有来头时钟配好之后我们定位到PC13引脚。两种方式一个是直接在右边芯片图上点击PC13另一个是在左边Categories里选GPIO然后看Pins列表。把PC13的模式设为GPIO_Output。这里有个关键坑蓝色板上那颗LED是接在PC13和GND之间的另一端通过电阻接3.3V所以PC13输出低电平时灯才会亮——这叫低电平点亮。所以GPIO输出Level里请选High这样一上电灯默认是灭的不会吓你一跳。然后GPIO模式选Output Push Pull推挽输出其实就是普通的强驱动输出速度随便选Low就行点灯用不到高速翻转。接着是工程配置。Project Manager页里Project Name随便起个stm32_cpp_blinkToolchain/IDE选Makefile这样CubeMX会生成一套GCC风格的Makefile工程。代码生成器设置里Generated files那个栏目下勾选“Generate peripheral initialization as a pair of .c/.h files per peripheral”不是必须的但建议保留默认。另外一定要在Code Generator里把“Insert default USER CODE sections”勾上这样你写的代码会放在USER CODE BEGIN/END中间CubeMX重新生成时不会被覆盖。最后点右上角GENERATE CODE等几秒工程就出来了。4. 把 C 工程改造成 C 工程别让 CubeMX 再覆盖你的代码4.1 推荐做法新建 app.cpp让 main.c 调用它CubeMX骨架上手就是C但这是一篇讲嵌入式C的文章所以我们要把C“塞”进工程。直接改main.c不行最省心的做法是新建一个app.cpp把业务逻辑放进去然后让main.c在while(1)里调一下它。这样CubeMX重新生成代码时只会动main.c不会碰我们的app.cpp从根上避免“我再生成一下工程你写的代码全没了”的惨案。先打开工程目录在Core/Src下新建app.cpp再在Core/Inc下新建app.h。app.h里面只放一个函数声明#pragma once #ifdef __cplusplus extern C { #endif void app_main(void); #ifdef __cplusplus } #endif这里加extern C是因为后面我们会在main.c一个C文件里调用app_main。C编译器会对函数名做name mangling也就是把函数名和参数类型编码成一个内部符号比如app_main可能会变成_Z8app_mainv。如果不在声明处加extern CC文件里是找不到这个函数的。加上之后编译器就知道这个函数按C语言规则生成符号两边才能正常链接上。app.cpp的骨架先写成这样#include main.h #include app.h extern C void app_main(void) { while (1) { } }你可能会问为什么app_main的定义还要再写一遍extern C因为C里函数的声明和定义要一致头文件里声明的extern C是给调用者看的定义处最好也明确标注否则有些编译器会认为定义的是C函数和声明的C Linkage对应不上。4.2 直接改名 main.cpp 的玩法和这个玩法的坑我猜很多人会看到网上教程说“把main.c改成main.cpp就行”这个思路没错但有几个坑必须提前说。第一CubeMX的Makefile里写死了Core/Src/main.c这个源文件你改了文件名后Makefile还在编译链接一个不存在的文件会直接报错。你必须打开Makefile在C_SOURCES那一段把Core/Src/main.c删掉再自己加一行CPP_SOURCES Core/Src/main.cpp之类并补上对应的编译规则。第二CubeMX重新生成代码时会自动重置工程配置如果你每次生成前不迁移自己的代码改名的劳动成果会被打回原形。所以我的建议是本期先用app.cpp方式等工程结构玩熟了再尝试“全员C化”也不迟。实际项目里不少团队也是保留一个main.c做引导核心逻辑全部放.cpp文件里这样既稳定又方便。4.3 extern “C”这个名字修饰机制要搞清楚刚才反复提到extern “C”这里展开讲讲为什么它是C/C混编的命门。C语言编译时函数名在符号表里就叫这个名字比如HAL_GPIO_WritePin就是HAL_GPIO_WritePin链接器按这个名字去找实现。C不一样因为要支持重载编译器会把函数名和参数类型、作用域等信息揉在一起生成一个乱七八糟的名字比如void foo(int)可能变成_Z3fooivoid foo(float)变成_Z3foof——这俩虽然都叫foo但符号完全不同。HAL库是纯C写的里面的函数没有经过C重载符号名就是原始函数名。如果你的C代码直接#include stm32f1xx_hal.h并调用HAL_GPIO_WritePinC编译器默认会按C方式生成对HAL_GPIO_WritePin的引用符号比如_Z18HAL_GPIO_WritePinP9GPIO_TypeDefm14GPIO_PinState而HAL库编译出来的符号还是HAL_GPIO_WritePin两边的符号对不上链接时就会报undefined reference to ...。解决方式就是让头文件里的函数声明处于extern C的作用域内。STM32的HAL头文件很贴心里面已经处理好了#ifdef __cplusplus extern C { #endif /* ... HAL函数声明 ... */ #ifdef __cplusplus } #endif所以在我们自己的C文件中直接#include main.h时HAL函数会自动按C链接处理不会出问题。但如果你的代码里包含一些老旧的第三方库头文件它们没做这个保护你就得自己包一层extern C。这是C移植经典项目时最容易踩的坑没有之一。5. 第一个正经 C 类LED 驱动与 400ms 闪烁5.1 头文件与实现一个类是这么写出来的现在到了全文最爽的部分——写代码。我先把led.h和led.cpp写出来然后逐行解释为什么这么设计。Core/Inc/led.h#pragma once #include stm32f1xx_hal.h class Led { public: Led(GPIO_TypeDef* port, uint16_t pin); void on(); void off(); void toggle(); private: GPIO_TypeDef* _port; uint16_t _pin; };Core/Src/led.cpp#include led.h Led::Led(GPIO_TypeDef* port, uint16_t pin) : _port(port), _pin(pin) { GPIO_InitTypeDef init {0}; init.Pin _pin; init.Mode GPIO_MODE_OUTPUT_PP; init.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(_port, init); } void Led::on() { HAL_GPIO_WritePin(_port, _pin, GPIO_PIN_RESET); } void Led::off() { HAL_GPIO_WritePin(_port, _pin, GPIO_PIN_SET); } void Led::toggle() { HAL_GPIO_TogglePin(_port, _pin); }这里有个设计思路值得展开说。我把构造函数做成“传入端口和引脚在构造函数里完成GPIO初始化”这样外部使用者创建一个LED对象时不需要关心底层寄存器怎么配直接Led led(GPIOC, GPIO_PIN_13);即可。这就是面向对象封装的目的复杂的东西藏起来暴露的接口简单清晰。on()和off()里为什么一个写GPIO_PIN_RESET、一个写GPIO_PIN_SET因为这是PC13低电平点亮我直接把“电平逻辑”封装进类的内部调用者只关心灯的亮灭不关心电平时序。如果你恰好用的是高电平点亮的板子改on()和off()里那两行就行不用动其他任何代码——这就是封装带来的好处。toggle()就是翻转调用HAL库的HAL_GPIO_TogglePin一句话完事。5.2 主循环里调起来点灯不再是一堆HAL宏接下来改app.cpp让主循环真正跑起来#include main.h #include app.h #include led.h extern C void app_main(void) { Led led(GPIOC, GPIO_PIN_13); while (1) { led.on(); HAL_Delay(200); led.off(); HAL_Delay(200); } }写完之后工程里涉及C的部分就是四个文件了led.h、led.cpp、app.h、app.cpp。main.c那边我们把app_main声明一下然后放进用户代码区域/* USER CODE BEGIN Includes */ #include app.h /* USER CODE END Includes */然后在while (1)循环里调用/* USER CODE BEGIN 3 */ app_main(); /* USER CODE END 3 */注意app_main函数体内部自己有个死循环永远不会返回所以外面这个while (1)实际上只调用一次就够了。你要是习惯让一切都从main里逻辑上看得见也可以改成app_main()放在/* USER CODE BEGIN 2 */里效果一样。这个设计在嵌入式里很常见main.c尽量保持精简真正业务逻辑全挪进单独的模块模块之间用明确接口沟通方便测试和维护。你以后写串口、写传感器驱动、写控制逻辑都可以按这个套路扩展。5.3 同为点灯C和C差在哪如果你用纯C写同样的点灯代码大概长这样void led_init(void) { GPIO_InitTypeDef init {0}; init.Pin GPIO_PIN_13; init.Mode GPIO_MODE_OUTPUT_PP; init.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOC, init); } void led_on(void) { HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_RESET); } void led_off(void) { HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET); } void app_main(void) { led_init(); while (1) { led_on(); HAL_Delay(200); led_off(); HAL_Delay(200); } }看着也还行对吧但如果你的板子上有5个LED、3个按键、2个传感器纯C写法就变成了每个设备都要写一组类似函数函数名要么叫led1_init、led2_init要么给函数传一堆参数比如led_init(GPIOC, GPIO_PIN_13, GPIO_MODE_OUTPUT_PP)。调用的地方也全是led_init(GPIOC, GPIO_PIN_13); led_on(GPIOC, GPIO_PIN_13);你自己都会看晕。C的对象在这里就把“同一种硬件功能的多个实例”这件事抽象得很好。每颗LED都是一个Led对象引脚不同而已外部接口完全一致。后面如果LED不止是开关需要做PWM呼吸灯你可以在Led类里加一个setBrightness(uint8_t)或者写一个子类覆盖on()方法调用方不需要改代码——这叫接口稳定、实现可替换是嵌入式代码工程化的第一步。6. 编译烧录看板子上的灯说话6.1 编译make 一把过不了很正常逐个看报错在终端进到工程目录先看一下Makefile里C_SOURCES有没有包含我们在Core/Src下新增的.cpp文件。CubeMX生成的Makefile默认只收集.c文件不会自动把.cpp加进编译列表所以需要手动加一下。在Makefile的C_SOURCES那一大段后面新增一行CPP_SOURCES \ Core/Src/app.cpp \ Core/Src/led.cpp然后检查链接选项。CubeMX的Makefile在LDFLAGS里默认不会带-lstdc如果你的C代码里没用到标准库我们这个例子确实没用到可能能过但为了稳妥建议在LDFLAGS里加上-lstdc这样即使后续你用到了std::vector、std::string链接时也不会“找不到符号”。关于编译规则Makefile里默认有%.o: %.c的规则但.cpp文件不会自动套用它。最简单的解决办法是让.cpp和.c共用同一套编译规则在Makefile末尾加一段%.o: %.cpp arm-none-eabi-gcc $(CFLAGS) -c $ -o $注意这里用的是arm-none-eabi-gcc它面对.cpp文件会自动调用C前端编译并链接相关的C标准库。这也是不少老项目能在GCC工具链下实现“一套编译规则同时处理c和cpp”的常用做法。一切就绪后执行make -j4你会看到一串编译输出最后生成build/stm32_cpp_blink.bin和.hex文件。如果报错也别慌最常碰到的两类问题我在下一节整理成表格了对着查就行。6.2 烧录ST-Link接线与命令编译通过后先把ST-Link和板子接好3V3接3V3、SWDIO接SWDIO、SWCLK接SWCLK、GND接GND。插上电脑USB确认设备管理器里能识别到一个ST-Link设备。用st-flash命令烧录这是stlink-tools里的工具st-flash write build/stm32_cpp_blink.bin 0x08000000或者用OpenOCDopenocd -f interface/stlink-v2.cfg -f target/stm32f1x.cfg -c program build/stm32_cpp_blink.bin 0x08000000 verify reset exit烧录完成后板子自动复位程序跑起来PC13那颗LED应该就以400ms的周期闪烁了——亮200ms、灭200ms。如果你用的是其他引脚看到的现象可能不太一样但只要灯按你的节奏闪就说明整条链路通了。7. 新手最容易踩的 6 个坑附速查表现象原因解决方法编译报undefined reference to HAL_GPIO_WritePin头文件没有按C语言链接方式声明确认#include main.h时HAL头文件里的extern C保护还在检查你自己的函数是否漏了extern C编译报cannot find -lstdc链接时没加C标准库在Makefile的LDFLAGS里加-lstdc下载时提示No ST-Link detected驱动没装或接线错误重装ST-Link驱动检查SWDIO/SWCLK是否交叉接反下载时报Error: flash write failed芯片Flash保护或供电不足用ST-Link的st-flash erase全片擦除再写确保USB供电足够别用劣质HUBPC13灯反向默认亮、代码让灭它反而亮低电平点亮被搞反记住蓝板LED是低电平亮Led::on()写RESET是对的用PA13/PA14/PA15/PB3/PB4做GPIO但没输出这些引脚默认是JTAG调试口在CubeMX里把对应引脚设为GPIO后重新生成代码会自动禁用JTAG如果是纯手写代码需要调用__HAL_AFIO_REMAP_SWJ_NOJTAG()关闭JTAG这里单独说一下最后一个坑。F103的PA13/PA14/PA15、PB3/PB4这几个引脚复位之后默认功能是JTAG调试接口。如果你直接在CubeMX里把它们配成GPIO输出并烧录程序运行后会把这些引脚切回GPIO功能但必须保证在初始化时正确关闭了JTAG复用。CubeMX在你把一个JTAG引脚配置成普通GPIO时会自动在GPIO_Init里做重映射处理。如果你是手工配置这些引脚一定要调用__HAL_AFIO_REMAP_SWJ_NOJTAG()或__HAL_AFIO_REMAP_SWJ_DISABLE()否则引脚永远拉不出你预期的高低电平。热词里那个“stm32禁用jtag”就是这么来的。如果你的灯是用PC13那这个坑暂时碰不上但迟早会在这几个引脚上翻车一次先记着。8. 最后聊两句点完灯之后怎么走这套流程跑通之后你手里已经有一套“CubeMX配置外设 C封装驱动 Makefile编译 ST-Link烧录”的完整工程骨架。后面加串口、加定时器、加传感器都是在这个骨架上做加法。我个人实际开发中这个模式用了很久都没换过因为每次重开一个项目CubeMX生成底层C类封装硬件业务逻辑写在app.cpp里结构非常清爽。如果你觉得不过瘾下一篇我准备写定时器中断。中断里直接调C的成员函数会有坑——因为中断服务函数是C链接的不能直接指向类的成员方法到时候我会讲怎么用静态成员函数或函数指针做桥接。另外你搜过的“stm32超声波测距”其实也可以放到这个框架里做无非就是多一个Hcsr04类的事。最后再分享一个小技巧把工程目录用git管起来每次烧录前git commit一次。嵌入式调试最怕“我啥也没改它就好了”和“我啥也没改它就不行了”有版本记录你至少知道代码是哪一刻变化的。这周催更的朋友可以拿着这篇去点灯了下期见。
返回列表