1. 先聊聊这个标题背后的情绪
“看了三篇了,一行都没让我写呢”——这句话我第一次看到的时候,差点笑出声。因为这几乎是每一个跟着系列教程学嵌入式C++的人,在第三篇结束时最真实的心理状态。前几篇大概率在讲环境搭建、工具链选型、CMake配置、VSCode插件安装、Renode仿真环境准备,全是“准备工作”,一行业务代码都没碰。你打开编辑器,光标在闪,但教程告诉你“先把工具装好”。
我特别理解这种感受。当年我从Keil MDK转到CMake+GCC工具链的时候,光是让编译器找到STM32的头文件就折腾了一个周末。所以这篇内容,就是要把“看了三篇还没写代码”这个尴尬局面彻底终结。我会从工具链的底层逻辑讲起,把CMake、Renode、VSCode这三件套的配合关系拆开揉碎,然后直接给你一段能在STM32上跑的C++代码,从编译到仿真到调试,一条龙走通。
这篇文章适合谁?如果你已经跟着前几篇装好了VSCode、装了CMake、下载了ARM工具链、甚至已经打开了Renode的界面但不知道下一步该干嘛,那这篇就是为你写的。如果你还没装环境,也没关系,我会在关键节点补充说明,让你知道每一步“为什么”要这么做,而不是无脑复制命令。
核心关键词我先自然带出来:STM32、嵌入式C++、CMake、Renode。这四个词贯穿全文,也是你从“看教程”到“写代码”必须跨过的四道坎。
2. 为什么前三篇都在讲工具链而不是写代码
2.1 嵌入式C++和普通C++的根本区别
很多人学C++是从PC端开始的,编译器是MSVC或者GCC,运行环境是Windows或Linux,内存随便用,堆栈随便开。但嵌入式C++完全是另一回事。你面对的是一个主频几十到几百MHz的MCU,RAM可能只有几十KB,Flash可能只有几百KB,没有操作系统(或者只有一个RTOS),没有标准库的完整支持,甚至连new和delete都要谨慎使用。
这就决定了嵌入式C++的项目结构、编译流程、调试手段和PC端完全不同。你不能直接#include <iostream>然后std::cout,因为根本没有控制台。你需要的是交叉编译工具链、链接脚本、启动文件、寄存器操作,以及一个能模拟硬件行为的仿真器。
前三篇之所以一直在讲工具链,是因为这些东西不搭好,后面写任何代码都是空中楼阁。CMake负责组织编译流程,ARM GCC负责把C++代码翻译成STM32能执行的机器码,Renode负责在没有真实硬件的情况下模拟STM32的运行行为,VSCode负责把这些工具串起来并提供编辑和调试界面。这四者缺一不可。
2.2 CMake在嵌入式项目中的角色
CMake不是编译器,它是一个构建系统生成器。你可以把它理解成一个“编译流程的项目经理”:它读取CMakeLists.txt里的指令,然后生成Makefile或Ninja文件,最后调用真正的编译器去干活。
在嵌入式项目里,CMake的价值在于跨平台和可复现。你可以在Windows上开发,在Linux上编译,在CI服务器上自动构建,只要CMakeLists写得好,结果是一致的。相比之下,Keil的工程文件是二进制格式的,很难做版本管理和自动化构建。
一个典型的STM32 CMake项目需要指定这些东西:目标平台(arm-none-eabi)、CPU架构(cortex-m4或cortex-m3)、浮点单元设置、链接脚本、启动文件、优化等级、调试信息格式。这些参数如果手动敲GCC命令,一行能写几百个字符,而且容易出错。CMake把这些参数固化在配置文件里,一次写好,到处能用。
2.3 Renode为什么比QEMU更适合STM32仿真
Renode是一个开源的仿真框架,专门为嵌入式系统设计。它和QEMU的最大区别在于:Renode可以仿真整个SoC的外设行为,包括GPIO、UART、SPI、I2C、定时器、中断控制器等。QEMU更偏向于系统级仿真,对MCU外设的支持反而不如Renode细致。
举个例子,你在Renode里可以让一个虚拟的UART输出字符到终端,也可以让一个虚拟的GPIO引脚状态变化触发中断。这些行为在QEMU里配置起来非常麻烦,但在Renode里只需要几行.resc脚本。
对于STM32开发者来说,Renode的最大好处是:你不需要买开发板就能验证代码逻辑。特别是当你手头没有硬件,或者硬件还没到货的时候,Renode能让你先把软件跑起来。当然,仿真和真实硬件还是有差异的,比如时序精度、外设行为的细节等,但对于学习阶段来说,完全够用。
3. 从零开始:让第一行C++代码在STM32上跑起来
3.1 项目目录结构设计
在写代码之前,先要把目录结构定好。我见过太多人把所有文件堆在一个文件夹里,最后自己都找不到哪个是哪个。嵌入式项目尤其需要清晰的目录划分,因为涉及的文件类型很多:源码、头文件、链接脚本、启动文件、CMake配置、Renode脚本、调试配置。
我推荐的结构是这样的:
stm32-cpp-demo/ ├── CMakeLists.txt # 顶层CMake配置 ├── cmake/ │ └── arm-gcc-toolchain.cmake # 工具链配置 ├── src/ │ ├── main.cpp # 主程序 │ ├── startup_stm32f4xx.s # 启动文件 │ └── system_stm32f4xx.c # 系统初始化 ├── include/ │ └── stm32f4xx.h # 寄存器定义头文件 ├── linker/ │ └── STM32F407VGTx_FLASH.ld # 链接脚本 ├── renode/ │ └── stm32f4.resc # Renode仿真脚本 └── .vscode/ ├── launch.json # 调试配置 └── c_cpp_properties.json # 智能提示配置这个结构的好处是职责分明。src放源文件,include放头文件,linker放链接脚本,renode放仿真脚本,.vscode放编辑器配置。CMakeLists在顶层统一管理,工具链配置单独放在cmake目录下,方便复用。
3.2 工具链文件的关键参数
arm-gcc-toolchain.cmake这个文件决定了CMake用哪个编译器、编译成什么架构、用什么浮点单元。下面是我实际在用的配置,针对STM32F407(Cortex-M4内核,带FPU):
set(CMAKE_SYSTEM_NAME Generic) set(CMAKE_SYSTEM_PROCESSOR arm) set(TOOLCHAIN_PREFIX arm-none-eabi-) set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}gcc) set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}g++) set(CMAKE_ASM_COMPILER ${TOOLCHAIN_PREFIX}gcc) set(CMAKE_OBJCOPY ${TOOLCHAIN_PREFIX}objcopy) set(CMAKE_SIZE ${TOOLCHAIN_PREFIX}size) set(CPU_FLAGS "-mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard") set(CMAKE_C_FLAGS_INIT "${CPU_FLAGS}") set(CMAKE_CXX_FLAGS_INIT "${CPU_FLAGS} -fno-exceptions -fno-rtti") set(CMAKE_ASM_FLAGS_INIT "${CPU_FLAGS} -x assembler-with-cpp") set(CMAKE_EXE_LINKER_FLAGS_INIT "${CPU_FLAGS} -T${CMAKE_SOURCE_DIR}/linker/STM32F407VGTx_FLASH.ld -nostartfiles")这里有几个关键点需要解释。-mcpu=cortex-m4指定CPU内核,-mthumb表示使用Thumb指令集(Cortex-M系列只支持Thumb),-mfpu=fpv4-sp-d16表示启用单精度浮点单元,-mfloat-abi=hard表示浮点参数用硬件寄存器传递。如果你的芯片是Cortex-M3或者没有FPU,这几个参数要相应调整。
-fno-exceptions -fno-rtti这两个C++编译选项在嵌入式里几乎是标配。异常处理和运行时类型识别会显著增加代码体积和运行时开销,在资源受限的MCU上通常不需要。关掉它们能让你的固件小很多。
-nostartfiles告诉链接器不要链接标准启动文件,因为我们自己提供了startup_stm32f4xx.s。这个启动文件里定义了中断向量表和复位处理函数,是STM32上电后执行的第一段代码。
3.3 链接脚本的核心作用
链接脚本(.ld文件)决定了代码和数据在内存中的布局。STM32F407有1MB的Flash和192KB的RAM(其中128KB是主RAM,64KB是CCM RAM)。链接脚本要告诉链接器:代码放Flash的哪个地址,数据放RAM的哪个地址,堆栈从哪里开始。
一个精简版的链接脚本关键部分是这样的:
MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 1024K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K CCMRAM (rwx): ORIGIN = 0x10000000, LENGTH = 64K } SECTIONS { .text : { . = ALIGN(4); *(.isr_vector) *(.text) *(.text*) *(.rodata) *(.rodata*) . = ALIGN(4); _etext = .; } > FLASH .data : { . = ALIGN(4); _sdata = .; *(.data) *(.data*) . = ALIGN(4); _edata = .; } > RAM AT> FLASH .bss : { . = ALIGN(4); _sbss = .; *(.bss) *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM }ORIGIN = 0x08000000是STM32 Flash的起始地址,这是芯片设计决定的,所有STM32都从这开始执行。.isr_vector段必须放在最前面,因为Cortex-M内核上电后从地址0x08000000读取栈顶指针,从0x08000004读取复位向量。
.data段的AT> FLASH表示数据段的初始化值存储在Flash里,但运行时地址在RAM里。启动文件里的代码会负责把这些值从Flash拷贝到RAM。.bss段存放未初始化的全局变量,启动文件会把它清零。
3.4 启动文件里到底发生了什么
startup_stm32f4xx.s是汇编文件,但它的逻辑很简单。上电后,CPU从向量表读取栈顶指针和复位向量,跳转到Reset_Handler。Reset_Handler做三件事:初始化系统时钟(调用SystemInit)、拷贝.data段、清零.bss段,最后调用main。
向量表的前几项是固定的:栈顶指针、复位向量、NMI、HardFault、MemManage、BusFault、UsageFault。后面是各种外设中断向量,比如EXTI0、USART1、TIM2等。如果你用到了某个外设中断,对应的处理函数名必须和向量表里的一致,否则中断触发后会跳到一个默认的死循环。
我见过有人把中断处理函数名拼错了,结果中断一触发程序就卡死,查了半天才发现是名字对不上。这种问题在仿真器里也一样会出现,因为Renode也是按照向量表来模拟中断行为的。
4. 写第一段真正的C++代码:GPIO闪烁与串口输出
4.1 用C++类封装GPIO操作
终于到写代码的环节了。我选择从GPIO开始,因为这是最直观的外设,能看到LED闪烁或者仿真器里的引脚状态变化。但我不想用C语言那种直接操作寄存器的方式,既然标题是“嵌入式C++”,那就用C++的方式来做。
先定义一个GPIO类:
class GpioPin { public: enum class Mode : uint32_t { Input = 0x00, Output = 0x01, Alternate = 0x02, Analog = 0x03 }; constexpr GpioPin(GPIO_TypeDef* port, uint8_t pin) : port_(port), pin_(pin) {} void setMode(Mode mode) const { volatile uint32_t* moder = &port_->MODER; *moder &= ~(0x3U << (pin_ * 2)); *moder |= (static_cast<uint32_t>(mode) << (pin_ * 2)); } void setHigh() const { port_->BSRR = (1U << pin_); } void setLow() const { port_->BSRR = (1U << (pin_ + 16)); } void toggle() const { port_->ODR ^= (1U << pin_); } private: GPIO_TypeDef* port_; uint8_t pin_; };这个类有几个设计考量。constexpr构造函数让编译器可以在编译期创建对象,减少运行时开销。setMode操作MODER寄存器,每个引脚占2个bit,所以要先清零再设置。setHigh和setLow用BSRR寄存器而不是ODR,因为BSRR是原子操作,不会被中断打断。toggle用ODR异或,虽然非原子,但在单线程环境下没问题。
GPIO_TypeDef是STM32头文件里定义的结构体,映射到外设寄存器的地址。比如GPIOA的地址是0x40020000,GPIO_TypeDef*指向这个地址后,->MODER就对应偏移0x00的寄存器,->BSRR对应偏移0x18的寄存器。
4.2 系统时钟初始化不能跳过
在操作GPIO之前,必须先使能GPIO端口的时钟。STM32的外设时钟默认是关闭的,不使能的话写寄存器没有任何效果。这个坑我踩过:代码逻辑没问题,但LED就是不亮,查了半天才发现是RCC时钟没开。
class RccClock { public: static void enableGpioA() { RCC->AHB1ENR |= (1U << 0); } static void enableGpioD() { RCC->AHB1ENR |= (1U << 3); } static void enableUsart2() { RCC->APB1ENR |= (1U << 17); } };AHB1ENR是AHB1总线的时钟使能寄存器,GPIOA在bit 0,GPIOD在bit 3。APB1ENR是APB1总线的时钟使能寄存器,USART2在bit 17。这些bit位置在参考手册里有详细说明,不同型号的STM32可能不一样,用之前一定要查对应型号的文档。
4.3 串口输出:嵌入式C++的“Hello World”
在PC上,std::cout << "Hello"就能输出。在STM32上,你需要配置UART外设,设置波特率、数据位、停止位,然后往数据寄存器里写字符。下面是一个简化的UART类:
class Uart { public: constexpr Uart(USART_TypeDef* instance) : instance_(instance) {} void init(uint32_t baudrate) const { // 假设APB1时钟为42MHz uint32_t apb1_clock = 42000000; uint32_t usartdiv = (apb1_clock + baudrate / 2) / baudrate; instance_->BRR = usartdiv; // 使能发送和接收,使能USART instance_->CR1 = (1U << 3) | (1U << 2) | (1U << 13); } void sendChar(char c) const { while (!(instance_->SR & (1U << 7))) {} instance_->DR = static_cast<uint32_t>(c); } void sendString(const char* str) const { while (*str) { sendChar(*str++); } } private: USART_TypeDef* instance_; };波特率计算是这里的关键。BRR寄存器的值等于fCK / baudrate,其中fCK是USART的时钟频率。STM32F407的USART2挂在APB1总线上,默认时钟是42MHz。如果波特率是115200,那么BRR = 42000000 / 115200 ≈ 364.58,取整后是365。实际波特率会有微小误差,但115200下误差在可接受范围内。
CR1寄存器的bit 3是TE(发送使能),bit 2是RE(接收使能),bit 13是UE(USART使能)。这三个必须都置1,UART才能工作。SR寄存器的bit 7是TXE(发送数据寄存器空),只有TXE为1时才能往DR写数据,否则会覆盖上一个还没发完的字节。
4.4 main函数:把所有东西串起来
现在把GPIO、RCC、UART组合起来,写一个完整的main:
#include "stm32f4xx.h" int main() { RccClock::enableGpioD(); RccClock::enableGpioA(); RccClock::enableUsart2(); GpioPin led(GPIOD, 12); led.setMode(GpioPin::Mode::Output); GpioPin txPin(GPIOA, 2); txPin.setMode(GpioPin::Mode::Alternate); Uart uart(USART2); uart.init(115200); uart.sendString("Hello from STM32 C++!\r\n"); while (true) { led.toggle(); uart.sendString("LED toggled\r\n"); for (volatile uint32_t i = 0; i < 1000000; ++i) {} } }这段代码做了几件事:使能GPIOD、GPIOA、USART2的时钟;配置PD12为输出(连接LED);配置PA2为复用功能(USART2的TX引脚);初始化UART为115200波特率;发送一条欢迎信息;然后进入无限循环,翻转LED并发送串口消息。
for (volatile uint32_t i = 0; ...)是一个简单的延时循环。volatile防止编译器优化掉这个循环。这种延时方式不精确,但用于演示足够了。实际项目中应该用SysTick定时器或者硬件定时器来做精确延时。
PA2配置为复用功能后,还需要设置AFR寄存器选择具体的复用功能编号。USART2的TX在PA2上对应AF7。这个细节我在这里省略了,但实际代码里必须加上,否则UART不会输出。这也是一个常见的坑:引脚模式设对了,但复用功能编号没设,结果引脚没有任何输出。
5. 用CMake把代码编译成STM32固件
5.1 顶层CMakeLists的写法
有了源码,接下来要用CMake把它编译成.elf和.bin文件。顶层CMakeLists.txt的内容如下:
cmake_minimum_required(VERSION 3.20) project(stm32-cpp-demo CXX C ASM) set(CMAKE_TOOLCHAIN_FILE ${CMAKE_SOURCE_DIR}/cmake/arm-gcc-toolchain.cmake) set(SOURCES src/main.cpp src/startup_stm32f4xx.s src/system_stm32f4xx.c ) add_executable(${PROJECT_NAME}.elf ${SOURCES}) target_include_directories(${PROJECT_NAME}.elf PRIVATE include) set_target_properties(${PROJECT_NAME}.elf PROPERTIES OUTPUT_NAME ${PROJECT_NAME} LINK_FLAGS "-Wl,-Map=${PROJECT_NAME}.map,--cref" ) add_custom_command(TARGET ${PROJECT_NAME}.elf POST_BUILD COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${PROJECT_NAME}.elf> ${PROJECT_NAME}.bin COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${PROJECT_NAME}.elf> )set(CMAKE_TOOLCHAIN_FILE ...)必须在project()之前调用,否则CMake会先用默认编译器配置一遍,再切换工具链时会出问题。这是CMake的一个硬性要求,很多人在这里踩坑。
add_custom_command在编译完成后自动执行objcopy生成.bin文件,并用size命令显示固件占用的Flash和RAM大小。这个信息在嵌入式开发中非常重要,因为Flash和RAM都是有限资源,超了就得优化。
5.2 编译过程与常见报错
配置好之后,在项目根目录执行:
cmake -B build -G Ninja cmake --build build第一条命令生成构建文件,-G Ninja指定用Ninja作为构建工具(比Make快)。第二条命令执行编译。如果一切顺利,build目录下会出现stm32-cpp-demo.elf和stm32-cpp-demo.bin。
常见的报错有这几类:
第一类是找不到头文件。比如fatal error: stm32f4xx.h: No such file or directory。这说明target_include_directories里的路径不对,或者头文件确实不在那个目录。检查include目录下是否有stm32f4xx.h,以及CMakeLists里的路径是否相对于项目根目录。
第二类是链接错误。比如undefined reference to _exit或者undefined reference to _sbrk。这是因为链接了标准库的某些函数,但嵌入式环境没有实现它们。解决方法是在链接选项里加-specs=nosys.specs,或者自己实现这些系统调用。
第三类是段溢出。比如.text section exceeds available space in FLASH。这说明代码太大了,超出了Flash容量。可以尝试提高优化等级(-Os)、关闭调试信息(-g0)、移除不必要的库函数。
5.3 用size命令看固件占用
编译成功后,size命令会输出类似这样的信息:
text data bss dec hex filename 3420 108 1648 5176 1438 stm32-cpp-demo.elftext是代码段大小,存在Flash里。data是已初始化全局变量的大小,运行时在RAM里,但初始化值存在Flash里。bss是未初始化全局变量的大小,运行时在RAM里,不占Flash。dec和hex是总大小的十进制和十六进制表示。
对于STM32F407来说,1MB Flash和128KB RAM,这个占用非常小。但随着项目变大,text和data会增长,需要定期检查是否接近容量上限。
6. 在Renode里跑起来:没有硬件也能调试
6.1 Renode脚本的基本结构
Renode通过.resc脚本描述硬件平台。一个最小的STM32F407仿真脚本长这样:
mach create "stm32f4" machine LoadPlatformDescription @platforms/cpus/stm32f4.repl sysbus LoadELF @build/stm32-cpp-demo.elf showAnalyzer sysbus.usart2 startmach create创建一个名为stm32f4的机器。LoadPlatformDescription加载平台描述文件,Renode自带了很多常见MCU的平台描述。LoadELF把编译好的固件加载到虚拟Flash里。showAnalyzer打开一个分析窗口,显示USART2的输出。start启动仿真。
运行这个脚本后,Renode会开始执行固件。如果一切正常,你会在UART分析窗口里看到“Hello from STM32 C++!”和不断重复的“LED toggled”。这说明你的代码在仿真环境里跑通了。
6.2 在Renode里观察GPIO状态
除了UART输出,Renode还能显示GPIO引脚的状态变化。在脚本里加上:
showAnalyzer sysbus.gpioPortD这样就能看到GPIOD各个引脚的波形。PD12会以一定频率翻转,对应代码里的led.toggle()。虽然看不到真实的LED闪烁,但波形图能直观地告诉你代码在运行。
Renode的GPIO分析器还支持触发条件,比如当某个引脚变为高电平时暂停仿真。这在调试时序问题时非常有用。你可以设置一个触发条件,让仿真在特定引脚状态变化时停下来,然后检查此时的寄存器值和变量状态。
6.3 仿真与真实硬件的差异
Renode虽然强大,但毕竟是仿真,和真实硬件有差异。最大的差异在时序上。仿真器里的指令执行速度取决于宿主机的性能,而不是真实MCU的主频。所以基于循环计数的延时在仿真里和真实硬件上表现完全不同。
另一个差异是外设行为的细节。比如ADC的采样噪声、UART的帧错误检测、SPI的时钟极性相位等,仿真器可能不会完全模拟。这些差异在开发初期影响不大,但在做精确时序控制或模拟信号处理时需要注意。
我的建议是:用Renode验证逻辑正确性,用真实硬件验证时序和电气特性。两者结合,效率最高。
7. 常见问题与排查技巧实录
7.1 编译通过但仿真没输出
这是最常见的问题。代码编译没问题,Renode也启动了,但UART窗口一片空白。排查思路如下:
先确认UART的时钟使能了没有。RCC->APB1ENR的bit 17必须为1,否则USART2的寄存器写入无效。再确认GPIO的复用功能配置对了没有。PA2必须设置为复用模式,并且AFR寄存器要选择AF7。最后确认波特率计算是否正确。如果BRR值偏差太大,Renode可能无法正确解析UART帧。
还有一个容易忽略的点:Renode的平台描述文件里,USART2的地址必须和代码里的一致。STM32F407的USART2地址是0x40004400,如果平台描述里写的是别的地址,代码写的寄存器就落到了错误的位置。
7.2 中断向量表不对导致HardFault
如果程序一运行就进入HardFault,大概率是向量表有问题。检查启动文件里的向量表是否和链接脚本里的.isr_vector段匹配。向量表的第一个字是栈顶指针,第二个字是复位向量,这两个值必须正确。
另一个可能的原因是栈溢出。STM32的栈默认从RAM末尾向下增长,如果栈空间不够,会覆盖其他数据。可以在链接脚本里增大栈的大小,或者在启动文件里修改_estack的值。
7.3 CMake找不到编译器
如果CMake报错说找不到arm-none-eabi-gcc,说明工具链没有安装或者没有加到PATH里。在Windows上,安装ARM GCC后需要把bin目录加到系统环境变量。在Linux上,可以用包管理器安装gcc-arm-none-eabi。
验证方法是打开终端,输入arm-none-eabi-gcc --version,如果能输出版本信息,说明安装成功。如果提示“command not found”,那就是PATH的问题。
7.4 Renode加载ELF失败
Renode加载ELF失败通常是因为ELF文件格式不对。用file命令检查一下:
file build/stm32-cpp-demo.elf输出应该是ELF 32-bit LSB executable, ARM, EABI5。如果是x86-64,说明编译时用错了工具链,可能CMake没有正确加载工具链文件。
还有一种情况是ELF文件太大,超出了Renode虚拟Flash的容量。检查链接脚本里的Flash长度是否和Renode平台描述里的一致。
8. 从仿真到硬件:下一步该做什么
代码在Renode里跑通之后,下一步就是烧录到真实硬件上。你需要一个ST-Link调试器,把STM32开发板和电脑连起来。然后用OpenOCD或者STM32CubeProgrammer把.bin文件烧录到Flash里。
烧录命令用OpenOCD的话是这样的:
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c "program build/stm32-cpp-demo.bin 0x08000000 verify reset exit"0x08000000是Flash的起始地址,verify表示烧录后校验,reset表示烧录后复位,exit表示完成后退出OpenOCD。
烧录成功后,开发板上的LED应该开始闪烁,串口应该输出消息。如果LED不亮,先检查硬件连接:LED的正负极是否接对,限流电阻是否合适,引脚是否和代码里的一致。如果串口没输出,检查USB转串口模块的TX/RX是否和STM32的RX/TX交叉连接,波特率是否匹配。
我个人在实际操作中的体会是:仿真环境能帮你排除80%的逻辑错误,但剩下的20%往往和硬件相关。比如引脚虚焊、电源不稳、晶振不起振,这些问题在仿真里永远不会出现,但真实硬件上可能让你折腾一整天。所以,仿真和硬件要交替验证,不要等到代码全写完才上硬件。