ARTICLE DETAIL

资讯详情

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

STM32 C++重构实战:从裸机到智能鱼缸控制器工程化

STM32 C++重构实战:从裸机到智能鱼缸控制器工程化 哟哟哟系列走到第6篇活儿终于要凑齐了。前面几篇咱们从寄存器摸到CMSIS从GPIO点灯到UART收发从ADC采样到PWM输出把STM32这块硬骨头啃了个半熟。可你发现没有代码虽然跑得动但全是C语言那种裸奔写法——一个main函数塞几百行改个引脚得满文件翻换个外设等于重写。这不叫嵌入式这叫燃烧生命。这篇我准备彻底换个玩法把之前那些能跑就行的C代码用C重构成一套能长期维护、能搬家复用的工程骨架。光重构也没意思干脆结合一个真实小项目来做——智能鱼缸控制器。一边把传感器、步进电机、屏幕、通信串起来一边把定时器捕获、状态机、环形队列这些C化之后特别好用的东西讲透。适合谁看一句话你会C、想上C、还想要一个能直接抄作业的STM32工程的人。看完你会有一种感觉嵌入式C不是花架子是真的能把人从寄存器里解放出来。1. 为什么我劝你在STM32上写C而不是继续用C硬刚先说个老生常谈但总被误解的问题单片机上的C到底行不行行而且不是勉强行是现代工具链下相当舒服的行。很多人一听C就想到虚函数表、STL容器、异常处理觉得Flash和RAM分分钟爆炸。实际上你完全不需要那些裸机C最诱人的地方是RAII、重载、模板和常量表达式这些特性在编译期就能把逻辑理清楚几乎不掉性能。我建议的编译配置是这样的GCC工具链下亲测稳定-stdgnu17 -fno-exceptions -fno-rtti -fno-threadsafe-statics -fno-use-cxa-atexit -fno-sized-deallocation关掉异常和RTTI很关键裸机上谁也不想为了一个throw引入一堆运行时库。-fno-threadsafe-statics也很重要C11标准要求静态局部变量的初始化是线程安全的STM32裸机没多核也没操作系统这种保护纯属浪费。还要注意一点我不会用new和delete要么在启动文件里给一块静态内存池然后重写operator new要么干脆所有对象都是全局构造。说得直白点裸机C的纪律是编译期能定的东西绝不留到运行时去折腾。那C到底比C好在哪举个例子我要封装一个LED灯。用C写你得定义引脚宏、初始化函数、置高置低函数写起来规规矩矩但用的时候满脑子都是这个宏对应哪块外设。用C写一个类就够了class Led { public: 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 on() const { HAL_GPIO_WritePin(port_, pin_, GPIO_PIN_SET); } void off() const { HAL_GPIO_WritePin(port_, pin_, GPIO_PIN_RESET); } void toggle() const { HAL_GPIO_TogglePin(port_, pin_); } private: GPIO_TypeDef* port_; uint16_t pin_; };用的时候全局放几个对象Led ledGreen(GPIOB, GPIO_PIN_0);然后想怎么点就怎么点。引脚关系、初始化逻辑全部封装进构造器里项目大了以后维护成本直线下降。这不是炫技是实实在在让代码变得像搭积木一样。2. 外设封装三板斧GPIO、串口和定时器捕获这一节咱们把最常用的三类外设做一次C化。我按从简单到复杂来GPIO开胃串口中场定时器压轴。2.1 GPIO封装把引脚关系写进类型里刚才的Led类其实就是GPIO封装的雏形但一个项目里不可能只有LED还有按键、继电器、光耦输出。我不建议为每个外设单独写类而是先写一个通用的GpioPin再用它组合出具体设备。enum class PinMode { InputFloating, InputPullUp, InputPullDown, OutputPushPull, OutputOpenDrain }; class GpioPin { public: GpioPin(GPIO_TypeDef* port, uint16_t pin, PinMode mode) : port_(port), pin_(pin) { GPIO_InitTypeDef init {0}; init.Pin pin; init.Speed GPIO_SPEED_FREQ_LOW; switch (mode) { case PinMode::InputFloating: init.Mode GPIO_MODE_INPUT; init.Pull GPIO_NOPULL; break; case PinMode::InputPullUp: init.Mode GPIO_MODE_INPUT; init.Pull GPIO_PULLUP; break; case PinMode::InputPullDown: init.Mode GPIO_MODE_INPUT; init.Pull GPIO_PULLDOWN; break; case PinMode::OutputPushPull: init.Mode GPIO_MODE_OUTPUT_PP; break; case PinMode::OutputOpenDrain: init.Mode GPIO_MODE_OUTPUT_OD; break; } HAL_GPIO_Init(port_, init); } bool read() const { return HAL_GPIO_ReadPin(port_, pin_) ! GPIO_PIN_RESET; } void write(bool value) const { HAL_GPIO_WritePin(port_, pin_, value ? GPIO_PIN_SET : GPIO_PIN_RESET); } private: GPIO_TypeDef* port_; uint16_t pin_; };注意一个经验GPIO第一次初始化时Speed别无脑拉满。LED、按键这类慢信号用LOW档就够了高频刷新屏的BSRR才需要HIGH档。速度档位越高EMI噪声和功耗越大很多玄学干扰其实都是这里惹出来的。再往上组合比如按键去抖逻辑我习惯把引脚去抖状态再包一层class Button { public: explicit Button(GpioPin pin) : pin_(pin) {} bool pressed() { bool level pin_.read(); if (level ! lastLevel_) { lastChangeTicks_ HAL_GetTick(); lastLevel_ level; } if (HAL_GetTick() - lastChangeTicks_ 20) { return level; } return prevStable_; } private: GpioPin pin_; bool lastLevel_ true; bool prevStable_ true; uint32_t lastChangeTicks_ 0; };这段代码的价值在于状态是对象自带的不需要全局变量不需要在main函数里开一个专门放按键状态的角落。很多朋友写中断扫描按键其实20ms的软件去抖在裸机轮询场景下完全够用还能少踩一段可怕的中断重入问题。2.2 串口封装环形队列是标配STM32串口带中断或者DMA的话必须要有一块缓冲区。我强烈建议直接上环形队列而不是简单数组。原因无他中断随时往队列塞数据主循环随时取数据环形队列天然适合这种单生产者单消费者的场景。一个极简但好用的FIFOtemplate typename T, size_t N class RingBuffer { public: bool push(const T item) { size_t next (head_ 1) % (N 1); if (next tail_) return false; // full data_[head_] item; head_ next; return true; } bool pop(T item) { if (tail_ head_) return false; // empty item data_[tail_]; tail_ (tail_ 1) % (N 1); return true; } size_t available() const { return (head_ N 1 - tail_) % (N 1); } private: T data_[N 1]; size_t head_ 0; size_t tail_ 0; };这里用N 1做数组长度是因为要区分空和满占用一个槽位作为哨兵。实际项目中我一般放256字节刚好把一个STM32的page cache塞满DMA中断里push主循环pop完全不打架。串口类封装成什么样直接提供write(const uint8_t*, size_t)和read(uint8_t*, size_t)两个方法内部把所有环形队列、互斥逻辑收拢。注意裸机没有锁但中断和主循环并发访问同一个队列时只需要保证单生产者单消费者再加一个__disable_irq()/__enable_irq()保护一下临界区就能做到安全。2.3 定时器输入捕获测频率的本质是测时间热词里那么多stm32定时器捕获测频率说明这确实是个绕不开的要点。C来写这个类可以把测量周期、采样窗口、溢出处理全部封装好。测量频率的基本思路定时器跑在内部时钟上输入捕获寄存器记录边沿到来的时刻。连续捕获两次上升沿时间差就是一个周期。class InputFrequencyMeter { public: InputFrequencyMeter(TIM_HandleTypeDef* htim) : htim_(htim) {} void start() { HAL_TIM_IC_Start_IT(htim_, TIM_CHANNEL_1); } void onCapture(uint32_t ccValue) { if (!hasFirstEdge_) { firstCapture_ ccValue; hasFirstEdge_ true; return; } uint32_t delta ccValue - firstCapture_; // 定时器计数器回绕也能正确相减 firstCapture_ ccValue; if (delta 0) { frequencyHz_ static_castfloat(htim_-Init.Prescaler 1) / (delta / static_castfloat(APB1TimerClockHz)); // 这里要结合你的定时器时钟源计算别死记公式 } } float frequencyHz() const { return frequencyHz_; } private: TIM_HandleTypeDef* htim_; uint32_t firstCapture_ 0; bool hasFirstEdge_ false; float frequencyHz_ 0.0f; };这个类的精华在delta ccValue - firstCapture_因为定时器计数器是16位或32位回绕的无符号减法天然能处理溢出所以看到负数差值别慌多想想补码。要测低频怎么办用捕获溢出中断软件扩展计数位数。测高频怎么办先分频PSC调大但分辨率会下降。这叫鱼和熊掌只能按需取舍。3. 传感器和电机接线实战超声波、DS18B20和五线四相步进电机接下来咱们把外围设备拨进来。鱼缸控制器用到的传感器不算高级但接线和时序踩坑极多。3.1 HC-SR04超声波测距先定KPI再动手超声波测距的原理不复杂Trig脚拉高10us以上模块自动发8个40kHz脉冲Echo脚输出高电平高电平持续时间就是声波来回的时间。class HCSR04 { public: HCSR04(GpioPin trig, GpioPin echo) : trig_(trig), echo_(echo) {} float measureCm() { trig_.write(false); delayMicros(2); trig_.write(true); delayMicros(10); trig_.write(false); // 等待Echo拉高超时保护 uint32_t timeout 100000; while (!echo_.read() timeout--) { delayMicros(1); } if (timeout 0) return -1.0f; uint32_t start DWT-CYCCNT; timeout 100000; while (echo_.read() timeout--) { delayMicros(1); } if (timeout 0) return -1.0f; uint32_t elapsed DWT-CYCCNT - start; float seconds elapsed / static_castfloat(SystemCoreClock); return seconds * 34300.0f / 2.0f; // 声速343m/s往返除2 } private: GpioPin trig_; GpioPin echo_; };这里我用DWT-CYCCNT来做微秒计时比HAL_GetTick()准得多也是Cortex-M内核自带的神器。注意要在启动时打开DWTCoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk;超声波有三个大坑。第一个是回声过长如果前面有窗帘、水管Echo可能会持续几十毫秒不加超时整个系统就挂了。第二个是声速受温度影响严谨的应加温度补偿公式是331.4 0.607 * Tm/s。第三个是连续测量要留至少60ms间隔否则接收端余震会干扰下一次测量鱼缸水面晃动时数据跳得厉害可以取三次中位数滤波。3.2 DS18B20温度采集1-Wire时序得用临界区鱼缸水温我用DS18B20防水探头十几块钱量程-55到125摄氏度够用。1-Wire总线的坑在于时序要求严格初始化、读位、写位每一步都有明确的us级要求。写C封装时我建议把总线级操作和传感器逻辑分开。class OneWire { public: explicit OneWire(GpioPin pin) : pin_(pin) {} bool reset() { pin_.modeOutput(); pin_.write(false); delayMicros(480); releaseLine(); delayMicros(60); bool presence !pin_.read(); delayMicros(420); return presence; } void writeBit(bool bit) { pin_.modeOutput(); pin_.write(false); delayMicros(bit ? 1 : 60); pin_.write(true); delayMicros(bit ? 60 : 1); } bool readBit() { pin_.modeOutput(); pin_.write(false); delayMicros(1); releaseLine(); delayMicros(14); bool bit pin_.read(); delayMicros(40); return bit; } private: GpioPin pin_; void releaseLine() { pin_.modeInputPullUp(); } };操作1-Wire期间所有中断都要暂时关闭否则时序直接崩。我在主循环里用__disable_irq()包住一次完整的DS18B20读温操作关中断时间大概几百微秒鱼缸这种场景完全没问题。如果做的是对实时性要求苛刻的产品建议换成__disable_irq只包最核心的位操作。3.3 五线四相步进电机八拍驱动表别背错热搜里有五线四相步进电机stm32这类电机在鱼缸上正好可以带动浮饲料喂食器。四相步进电机按1-2相励磁方式驱动一个完整周期是8拍我建议用查找表而不是逐行写逻辑class StepperMotor { public: StepperMotor(GpioPin a, GpioPin b, GpioPin c, GpioPin d) : pins_{a, b, c, d} {} void step(bool forward) { static const uint8_t sequence[8][4] { {1, 0, 0, 0}, {1, 1, 0, 0}, {0, 1, 0, 0}, {0, 1, 1, 0}, {0, 0, 1, 0}, {0, 0, 1, 1}, {0, 0, 0, 1}, {1, 0, 0, 1} }; currentStep_ forward ? (currentStep_ 1) % 8 : (currentStep_ 7) % 8; for (int i 0; i 4; i) { pins_[i].write(sequence[currentStep_][i]); } } private: GpioPin pins_[4]; uint8_t currentStep_ 0; };步进电机最容易被忽略的是启动频率。如果你在两个相邻步之间只等1ms电机可能直接抖动而不转动这叫失步。解决办法是控制步进频率起步时稍微慢一点几毫秒之后再加速。还有一点喂食电机停下来后最好把四个线圈全部置低否则某两个相仍处于励磁状态电机处于锁轴但发热严重时间长了线圈会烫。3.4 ILI9341读ID是0xA1A1一次典型的假ID踩坑热词里有个stm32使用ili9341读id是a1a1这个坑希望你有心理准备。ILI9341手册上读ID寄存器0xD3高字节应该是0x94低字节是0x93。结果你用标准SPI读回来是个0xA1A1懵逼了吧先说结论0xA1A1不是真正的芯片ID而是这块屏幕模组厂商在该地址放的别的内容或者你的SPI配置有问题。我遇到的情况多数是这两种第一种MCU的SPI工作在主模式但ILI9341要求CMD和DATA在同一个8位字节里区分读ID时的一些字节需要用9位指令模式——如果你只是硬发8位命令和数据边界错位读回来的自然全是0xA1。第二种屏幕模组没有用9位SPI模式直接用MCU并行模式SPI读不到ID这时别死磕直接按ILI9341初始化序列初始化屏幕能亮就行。正确的读ID代码长这样uint16_t readLcdId() { uint8_t cmd 0xD3; uint8_t data[4] {0}; sendCommand(cmd, 1); // 第一个字节是dummy忽略 readData(data[0], 1); readData(data[1], 1); readData(data[2], 1); readData(data[3], 1); // data[1]和data[2]通常是ID的高字节和低字节 return (data[1] 8) | data[2]; }需要特别强调的是屏幕驱动这种模块查ID只是为了确认走哪套初始化序列。真遇到读回来等于0xA1A1先把LCD_RESET拉低再拉高、延时100ms之后再去读很多模组要复位后才能稳定响应。实在不行就直接按ILI9341标准初始化数组初始化90%的情况屏幕照样正常显示。别在ID上死等项目联调最忌讳钻这种牛角尖。4. 鱼缸控制器的系统整合从大循环到简易调度器外设都搞定了接下来咱们把它们拼成一个整机。鱼缸控制器功能大概有读取水温、测水位、步进电机喂食、OLED/LCD显示状态、按键设置参数、CAN/串口上报数据。这些功能如果全挤在一个while(1)里逻辑会非常混乱。我在这套工程里引入一个非常轻量的轮询调度器不需要RTOS只用数组注册任务每个任务有自己的周期和上次运行时间戳struct Task { void (*func)(void); uint32_t periodMs; uint32_t lastRunMs; }; class Scheduler { public: void registerTask(void (*func)(void), uint32_t periodMs) { tasks_[count_] {func, periodMs, 0}; } void run() { uint32_t now HAL_GetTick(); for (uint32_t i 0; i count_; i) { if (now - tasks_[i].lastRunMs tasks_[i].periodMs) { tasks_[i].lastRunMs now; tasks_[i].func(); } } } private: Task tasks_[8]; uint32_t count_ 0; };为什么不用FreeRTOS鱼缸控制器任务极其简单跑系统反而引入优先级、同步、内存管理这些复杂度。裸机调度器一行代码就能搞定也更好调。任务划分我建议这样投票式按键扫描50ms一跑超声波测距200ms一跑DS18B20温度1秒一跑LCD刷新500ms一跑CAN上报500ms一跑。注意调度器不是抢占式的任何一个任务里千万不能长时间阻塞比如超声波等待Echo的循环里一定要加超时否则整个调度器卡死。鱼缸控制还有个容易翻车的地方加热棒和温度传感器放的位置。我一开始把DS18B20紧挨着加热棒水温读得飞高然后系统拼命关加热结果整缸水温波动剧烈。后来把探头挪到远离加热棒且水流可以循环经过的位置温度曲线才平稳。做嵌入式很多时候是硬件位置比代码还重要。4.1 状态机控制逻辑别用一堆if堆鱼缸控制器需要一个自动恒温简单状态机空闲-加热-恒温-异常。用C枚举加switch比一堆if和标志位清晰得多enum class HeaterState { Idle, Heating, Holding, OverheatProtect }; class HeaterController { public: HeaterState state() const { return state_; } void update(float temperature) { switch (state_) { case HeaterState::Idle: if (temperature targetTemp_ - 0.5f) state_ HeaterState::Heating; break; case HeaterState::Heating: if (temperature targetTemp_) state_ HeaterState::Holding; if (temperature targetTemp_ 5.0f) state_ HeaterState::OverheatProtect; break; case HeaterState::Holding: if (temperature targetTemp_ - 1.0f) state_ HeaterState::Heating; break; case HeaterState::OverheatProtect: if (temperature targetTemp_ - 2.0f) state_ HeaterState::Holding; break; } relay_.write(state_ ! HeaterState::Idle state_ ! HeaterState::OverheatProtect); } private: HeaterState state_ HeaterState::Idle; float targetTemp_ 26.0f; GpioPin relay_ {GPIOB, GPIO_PIN_5, PinMode::OutputPushPull}; };状态机的价值在于每个时刻系统处于什么状态一目了然状态迁移非常明确不会出现加热棒又开了但温度明明够高这种逻辑风暴。4.2 CAN通信突然连不上的排查方法热搜里有stm32 can通信突然连不上这问题在鱼缸控制器里可能没有但很多朋友做CAN总线的都会遇到我必须单开一节。我做电机控制板时好几次碰到类似现象早上还能通信下午再上电就收不到数据。排查CAN不通信的黄金步骤量总线波形。用示波器看CAN_H和CAN_L的差分电平正常显性位大约是2V左右差STM32带收发器时CAN_H约3.5VCAN_L约1.5V如果两个引脚都接近0V大概率是收发器供电或者PCB焊接问题。如果没有示波器用万用表粗略看CAN_H和CAN_L之间的电阻正常是60欧左右两个节点配合终端电阻如果接近120欧说明有一边的60欧匹配电阻断了。检查终端电阻。CAN总线两端各需要120欧终端电阻。很多人只在一个节点上加了120欧或者干脆没加通信距离稍长就会出现时好时坏。确认波特率一致。千万别用看起来都是500k这种说法。位时间由预分频、时间段、重新同步段共同决定两边配置不一样就会间歇性丢帧。我会用CAN分析仪配合实际发送出来测量波特率最稳。看错误寄存器。用HAL库的话读hcan-ErrorCode如果是HAL_CAN_ERROR_ACK说明总线上有设备接收但没有正确回应多半是波特率不匹配。如果是HAL_CAN_ERROR_BUSOFF说明发送错误太多节点已经被总线离线需要软件恢复。检查软件配置遗漏。很多人只初始化CAN过滤器但忘了使能全局中断或者中断优先级配得比别的设备低导致数据一直被抢占表现也是突然连不上。给一个快速验证的思路写一个循环只发一帧数据用逻辑分析仪看总线。只要能抓到帧头说明物理层没问题剩下的就是软件上找过滤器、中断、邮箱管理。4.3 VSCodeJ-Link调试配置别再用串口打印猜问题了热词里vscode配置stm32开发环境和j-link下载环境出现不少我就直接给配置。现在很多新入行的朋友喜欢用VSCode而不是Keil这很正常。我个人的组合是VSCode编辑 CMake构建 arm-none-eabi-gcc编译 J-Link Cortex-Debug插件调试。launch.json核心配置长这样{ version: 0.2.0, configurations: [ { name: STM32 Debug, type: cortex-debug, request: launch, servertype: jlink, device: STM32F103C8, interface: swd, executable: build/firmware.elf, svdFile: STM32F103.svd, runToEntryPoint: main, swoConfig: { enabled: true, cpuFrequency: 72000000, swoFrequency: 2000000 } } ] }这里有个关键点SWO打印。如果你用J-Link的SWD调试器可以启用SWO引脚输出printf完全不用占用串口。但要注意SWO输出频率和CPU频率必须配置正确不然打印出来全是乱码。之前我在F103上CPU 72MHzSWO设2MHz一切正常后来换成F407CPU主频168MHz忘了改配置打印的信息像天书一样。别笑这种事谁都会遇到。4.4 网上数据上报方案巴法云与温度记录鱼缸控制器如果要做人不在家也能看水温的功能可以接一个ESP8266或者直接用STM32的以太网模块把数据上报到巴法云这样的免费物联网平台。热搜里也出现了stm32 巴法云。核心思路就是STM32采集温度通过AT指令或者MQTT协议上报到服务器手机端订阅同一个topic就能看到数据。ATTCMQTTSTART // 启动MQTT ATTCMQTTCONN1,你的ClientID // 连接服务器 ATTCMQTTSUB0,Topic名,0 // 订阅主题 ATTCMQTTPUB0,Topic名,温度:26.5,0 // 发布数据这类方案唯一要注意的是字符串拼接用C的字符串处理比C的strcat舒服太多但千万别在内存紧张的STM32上直接用std::string手写一个小的字符串构建器或者干脆用sprintf到固定缓冲里。5. 联调过程中的问题速查与调试心得联调阶段往往是噩梦把问题收集成表下次直接查比上网重新搜一遍更快。现象可能原因排查手段超声波数据偶尔为0Echo超时或测量间隔太短加超时保护两次测量间隔大于60msOLED/LCD白屏SPI速率太高或初始化序列不对降SPI时钟到10MHz以下核对ID后初始化步进电机抖动不转步进频率太高造成失步放慢步进间隔到3ms以上检查相序表CAN发帧后总线关闭采样点配置不当或终端电阻缺失检查总线电阻用示波器看波形串口接收乱码波特率偏差或输入时钟配置不对用示波器量波形验证实际波特率J-Link连接不上SWDIO/SWCLK接线反了或目标电压过低量目标板电压检查JTAG线序复位后程序不跑Boot引脚电平不对或供电不稳检查BOOT0/BOOT1用示波器看供电波形再说一个C裸机上特别容易踩的雷优化等级不同行为完全不一样。很多人在-O0调试一切正常一开-O2就崩溃第一反应是编译器bug。绝大多数情况是你的代码里存在未定义行为比如有符号整数溢出、越界访问数组、依赖寄存器中间值。排查方法是打开-Wall -Wextra -Werror把大部分问题扼杀在编译期。还有中断回调函数里要是有长时间循环或printf在低优化等级下勉强跑得动在高优化等级下可能会导致栈溢出或者中断响应超时。这个我在实际项目里吃过亏一个ISR里打印调试信息Debug版没事Release版直接复位后来把打印移出去才好。另一个雷是静态全局对象的构造顺序。C的全局对象构造函数执行顺序在C标准里是同一翻译单元内按声明顺序跨翻译单元不确定。假如你在一个文件里定义串口对象另一个文件里用它而这个串口对象依赖的硬件时钟还没初始化构造顺序错乱就会出现串口对象已经构造完但底层外设没开。解决办法不要在构造函数里操作硬件把初始化拆到init()方法手动在main()的开头按顺序调用。或者把所有依赖硬件的对象放到同一个.cpp文件里依靠C保证同一文件的全局对象按声明顺序构造这个特性。我一般选择后者简单暴力。还有volatile关键字嵌入式C里仍然需要但别滥用。中断标志、DMA缓冲、寄存器映射这些跨上下文访问的变量必须加volatile。可如果你的C代码里用了std::atomic它比volatile更严格因为编译器不会随便重排原子操作的顺序。硬件寄存器用volatile没问题共享变量建议用std::atomic。调试心得我再贡献一个善用断言。裸机C里我会放一个轻量宏#define ASSERT(cond) do { \ if (!(cond)) { \ errorHandler(__FILE__, __LINE__, #cond); \ } \ } while (0)errorHandler里可以点亮错误LED、打印文件名行号或者进入一个while循环等待调试器。调试期开启发布期关闭这个习惯帮我抓到了好几个参数不该越界的bug比打补丁强。6. 我实际做完这套东西之后想跟你说几句体己话鱼缸控制器这个项目带带我周围不少朋友有人问我你写了这么多C万一以后换了一个不支持C的编译器怎么办其实现在的ARM GCC和Clang都原生支持C11/14/17主流的厂商IDE比如STM32CubeIDE也默认能编译C你几乎找不到一个现代嵌入式工具链还不支持C。退一万步讲即使哪天换了芯片算法逻辑和类封装是可以整体迁移的这恰恰是C给嵌入式带来的最大红利。还有一个小技巧如果你在代码里大量使用模板编译后Flash可能膨胀。别慌逐个检查模板是否真的被实例化实在不行把一些模板函数改成普通函数。我自己处理过一次一个带模板的read()函数在10个模块里被实例化了10份Flash直接加了8KB后来改成参数化的普通函数缩小了一半。嵌入式的内存资源永远有限写C也不能飘。这一篇没有新的外设介绍但比介绍外设重要得多咱们把外设装进了人话能懂的类里把逻辑梳理成了状态机还把一个鱼缸控制器完整落地了出来。你手头如果有STM32开发板建议别急着往下学新东西先把你之前写的那些裸奔C代码挑一个模块重写成C感受一下封装前后的区别。下一次咱们可以聊聊USB设备怎么做、或者接一个真正的RTOS进来让调度器退休。活儿还在后头慢慢来机器都通上电了接下来就是咱们这一代嵌入式工程师把代码写顺溜的时刻。
返回列表