ARTICLE DETAIL

资讯详情

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

C++单片机实战:从LCD驱动到状态机与崩溃排查

C++单片机实战:从LCD驱动到状态机与崩溃排查 1. 为什么第二篇决定写这些内容上一篇我聊了C在单片机上的入门问题开发环境怎么搭、C和C在语法层面的主要差异、为什么这年头值得在单片机上看一眼C。文章发出去之后后台收到不少留言有刚接触C51的在校学生也有已经用Keil写了好几年C代码、想给老项目换血的老工程师。大家的疑问其实高度一致语法我大致看懂了但真正落到一个具体项目里从哪下手封装成class之后代码体积会不会爆炸运行起来会不会比纯C慢一大截这些问题光靠“C语法教程”是回答不了的。语法只是工具真正值钱的是设计思路——在资源受限的8位、32位MCU上什么东西适合抽成类什么东西就应该老老实实写函数什么时候用虚函数是省事什么时候用虚函数是在给自己埋雷。所以这一篇我打算换个讲法不再列语法点而是带着项目往前走。我会用我实际做过的几个小东西当例子LCD1602显示驱动、四位一体数码管3461BS的扫描显示、51单片机上的密码锁、触摸屏坐标从裸ADC值到屏幕像素的映射最后再复盘一次真实发生的access violation c0000005崩溃排查过程。这几块内容彼此独立但合起来就是C在单片机上从“会写”到“会设计”的一条相对完整的路线。这篇更适合已经能用手写C代码点亮LED、驱动过一两块外设的读者。如果你还完全没有单片机基础建议先把第一篇和一份C51入门教程看一遍再回来。如果你已经在项目里用C写过驱动这一篇的很多代码思路可以直接平移过去尤其是状态机和坐标映射那部分就算不换C单纯把函数接口重新梳理一遍代码质量也能上一个台阶。2. 用C写LCD1602驱动我建议你先别急着上类2.1 从函数集合到class我的演进路线LCD1602是很多人的入门外设网上一搜就是一堆C代码lcd_init()、lcd_write_cmd()、lcd_write_data()、lcd_putchar()……全是全局函数外加一组全局变量或者宏定义。这套写法在单个文件里其实没毛病甚至可以说非常清晰。但一旦项目变大驱动的引脚变了或者同一块屏要接两套实例问题就来了全局变量只有一份函数和具体引脚的关系完全靠硬编码。我的做法是分两步走。第一步先把所有操作LCD的函数封装成一个结构体把引脚编号和当前光标位置放进结构体里。这在纯C里也能做本质上就是“手动面向对象”。第二步才是用C的class让构造函数负责初始化引脚让成员函数天然带一个对象上下文。比较关键的一点我推荐用具体类不要在第一步就搞抽象基类。原因很实际——单片机上最值钱的资源是Flash和RAM虚函数表要占Flash虚函数调用要跳转虽然每次调用只多几条指令但驱动里最频繁的写字符操作要是走虚函数可能拖慢刷新速度而且在Keil这种老旧的编译器环境下虚函数和类继承报错信息又长又难懂新人排查起来很痛苦。2.2 一个可以“抄作业”的LCD1602类骨架下面这段代码来自我做的一个STC单片机项目4位数据线模式引脚接法在构造函数里传入。你可以直接拿去改。class LCD1602 { public: LCD1602(uint8_t rs, uint8_t en, uint8_t d4, uint8_t d5, uint8_t d6, uint8_t d7) : _rs(rs), _en(en), _d4(d4), _d5(d5), _d6(d6), _d7(d7), _row(0), _col(0) {} void init() { // 引脚全部设为推挽输出 setPinMode(_rs, OUTPUT); setPinMode(_en, OUTPUT); setPinMode(_d4, OUTPUT); setPinMode(_d5, OUTPUT); setPinMode(_d6, OUTPUT); setPinMode(_d7, OUTPUT); delay_ms(50); // 进入4位总线模式的标准初始化时序 writeNibble(0x03); delay_ms(5); writeNibble(0x03); delay_ms(5); writeNibble(0x03); delay_ms(5); writeNibble(0x02); writeByte(0x28, CMD); // 4位、2行、5x7点阵 writeByte(0x08, CMD); // 显示关闭 writeByte(0x01, CMD); // 清屏 delay_ms(2); writeByte(0x06, CMD); // 光标右移 writeByte(0x0C, CMD); // 显示开、光标关 } void setCursor(uint8_t col, uint8_t row) { _col col; _row row; uint8_t addr (row 0) ? (0x80 col) : (0xC0 col); writeByte(addr, CMD); } void putChar(char c) { writeByte((uint8_t)c, DATA); } void putString(const char* str) { while (*str) { putChar(*str); } } void clear() { writeByte(0x01, CMD); delay_ms(2); _row 0; _col 0; } private: static const uint8_t CMD 0x00; static const uint8_t DATA 0x01; uint8_t _rs, _en, _d4, _d5, _d6, _d7; uint8_t _row, _col; inline void setPinMode(uint8_t pin, uint8_t mode) { // 这里调用你所在平台的点亮/输入模式设置函数 } inline void writePin(uint8_t pin, uint8_t level) { // 数字输出 } void pulseEnable() { writePin(_en, 1); delay_us(10); writePin(_en, 0); delay_us(10); } void writeNibble(uint8_t data) { writePin(_d4, (data 0) 1); writePin(_d5, (data 1) 1); writePin(_d6, (data 2) 1); writePin(_d7, (data 3) 1); pulseEnable(); } void writeByte(uint8_t data, uint8_t mode) { writePin(_rs, mode); writeNibble(data 4); writeNibble(data 0x0F); } };这套代码相对于传统C函数最直观的变化是一个项目里如果同时接了两块LCD1602我只需要创建两个对象引脚配置互不干扰。这在做带副屏的仪表、双显示面板的控制器时非常有用。2.3 用类的成本构造、析构、虚函数的隐形开销很多工程师抵触C理由就是“类的开销大”。这个说法一半对一半错。具体类、没有虚函数、没有继承链的class在编译后基本就是一组普通函数函数第一个参数偷偷放了个this指针而已。你可以把Keil MAP文件里生成的代码量对比一下同一个驱动逻辑C版函数集合和C具体类之间Flash占用差异通常不超过几十个字节。真正带来成本的是虚函数、模板、异常和动态内存。虚函数会让每个对象多一个vptr指针还要在Flash里放一张虚表模板如果多个类型各展开一份Flash消耗成倍增长异常更是直接需要额外的运行时支持库裸机上一般直接关掉。所以结论很清晰不是C贵是某些贵族特性贵。2.4 什么时候才真的需要抽象前面说不建议上来就抽象基类但有一种情况例外——你需要同一套应用代码跑在不同硬件上比如项目要兼容STC和STM32两个平台LCD驱动代码全部抽成接口用派生类分别实现不同平台的引脚操作。这时候虚函数和抽象类的价值就充分显露了应用层只跟LCD1602的抽象接口打交道换平台只要换构造对象那一行代码。代价是存储空间多占一点换来的是整个应用层可移植。具体怎么权衡看项目定位一次性演示用的板子没必要要量产且可能有多个硬件版本的产品可以考虑。3. 数码管、按键和状态机C在裸机上最舒服的发挥场景3.1 3461BS数码管驱动把复杂扫描逻辑藏起来四位数码管3461BS是最常见的共阴数码管之一四个位选由P0.0到P0.3控制八个段选由P1口或者74HC595扩展输出。要显示四位数就必须不停地动态扫描每秒至少刷新50次否则肉眼会看到明显闪烁。用C写这种扫描通常就是全局变量定时器中断里面放一个counter和一个显示缓冲数组。用C写我会把这部分逻辑收拢成一个Display类class FourDigitDisplay { public: FourDigitDisplay(uint8_t segPort, uint8_t bit0, uint8_t bit1, uint8_t bit2, uint8_t bit3) : _segPort(segPort), _digit(0) { _bitPin[0] bit0; _bitPin[1] bit1; _bitPin[2] bit2; _bitPin[3] bit3; _buffer[0] 0; _buffer[1] 0; _buffer[2] 0; _buffer[3] 0; } void setNumber(uint16_t value) { _buffer[0] value / 1000; _buffer[1] (value / 100) % 10; _buffer[2] (value / 10) % 10; _buffer[3] value % 10; } void setDP(uint8_t pos, bool on) { _dpState[pos] on; } void scanTick() { // 中断里每1ms调用一次 turnOffAllBits(); outputSegment(_buffer[_digit], _dpState[_digit]); turnOnBit(_digit); _digit (_digit 1) 0x03; } private: uint8_t _segPort; uint8_t _bitPin[4]; uint8_t _digit; uint8_t _buffer[4]; bool _dpState[4]; void turnOffAllBits() { // 位选全部拉低 } void turnOnBit(uint8_t pos) { // 拉高对应的位引脚 } void outputSegment(uint8_t num, bool dp) { static const uint8_t codeTable[10] { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F }; // 把段码输出到段选引脚dp为小数点亮 uint8_t code codeTable[num]; if (dp) code | 0x80; // 输出code到_segPort } };这个类最大的价值是把“中断扫描”和“业务赋值”彻底隔离开了。主循环里只需要调用setNumber、setDP而扫描Tick放在定时器中断里。中间那层“什么时候该切换到下一位”的细节被完全封装掉后面写逻辑的人根本不用关心。3.2 按键消抖与单击/长按识别用类保存按键状态按键处理在C代码里最常见的写法是外部中断检测下降沿中断里delay一下消抖然后置一个flag。这种写法有两个问题delay会卡住主循环长按和短按的区分要写一堆零散的状态判断。C里我会用一个简洁的key状态机类enum class KeyEvent { None, Click, LongPress }; class KeyScanner { public: KeyScanner(uint8_t pin, uint16_t longPressMs) : _pin(pin), _longPressMs(longPressMs), _state(State::Idle), _pressTick(0) {} KeyEvent tick(uint16_t currentMs) { bool level readPin(); switch (_state) { case State::Idle: if (level PRESSED) { _pressTick currentMs; _state State::Pressed; } break; case State::Pressed: if (level RELEASED) { // 在释放时判断是单击还是长按 _state State::Idle; if (currentMs - _pressTick _longPressMs) { return KeyEvent::Click; } } else if (currentMs - _pressTick _longPressMs) { _state State::Idle; return KeyEvent::LongPress; } break; } return KeyEvent::None; } private: enum class State { Idle, Pressed }; uint8_t _pin; uint16_t _longPressMs; State _state; uint16_t _pressTick; };这个类的典型调用方式是在一个10ms的定时器中断里tick一次返回的事件交给主循环处理。消抖不需要额外的delay因为按键状态的分支跳转天然带有时间窗口效果。如果连续两次tick都在Pressed状态且持续时间小于10ms就会被识别成虚假抖动。逻辑上等于把消抖和长按识别揉到了一起。实测下来这个方案在51单片机上跑得很稳中断里只做状态判断和基本运算没有阻塞所以数码管扫描和按键扫描可以同时存在互不拖累。3.3 一个密码锁小项目里的状态机写法51单片机密码锁是很多毕设题目我见过的大多数C语言实现都是一个大循环里嵌套if-else判断“当前处于哪个阶段”变量满天飞改一个逻辑要顺藤摸瓜半天。用C的enum class加上一个主状态变量结构会清晰很多enum class LockState { WaitingInput, CheckPassword, Unlocked, Locked };核心状态流转LockState state LockState::WaitingInput; // 主循环里这样调度 switch (state) { case LockState::WaitingInput: if (keyEvent KeyEvent::Click) { inputBuffer[pos] currentKey; } if (pos 4) { state LockState::CheckPassword; } break; case LockState::CheckPassword: if (memcmp(inputBuffer, storedPassword, 4) 0) { state LockState::Unlocked; } else { state LockState::Locked; } break; // ... }配合2.3节的KeyScanner类按键事件和业务状态机完全解耦。调试时你只需要盯着state和当前按键事件不需要去翻一堆全局flag。这种结构即使后来加“设置密码”“超时重锁”等功能也只是增加几个枚举值不至于把代码改成意大利面。4. 触摸屏坐标映射从ADC裸数到屏幕像素坐标的通用算法4.1 先想明白“两套坐标系”这件事网上有几个高频搜索词点破了新手最常卡住的点单片机从触摸屏上获取到触摸点坐标后如何对应到屏幕上内容。说白了触摸屏本身输出的是一组模拟电压值通过X和Y两个通道的ADC采样变成数字量。这个数字量跟你在TFT屏幕上看到的像素坐标0~2390~319之间没有天然对应关系必须做一次映射换算。以我手头的一块STM32F103C8T6驱动TFT的项目为例触摸屏的X通道ADC读出范围大约是240到3950Y通道大约285到3810屏幕分辨率是320×240。直接用原始ADC值去比对按钮位置肯定对不齐。必须找到一条转换公式把ADC值区间映射到屏幕坐标区间。4.2 线性映射公式推导假设触摸屏ADC值和屏幕坐标之间近似线性关系那么最常用的公式是screenX (rawX - rawXMin) * (screenXMax - screenXMin) / (rawXMax - rawXMin) screenXMin这里rawXMin和rawXMax是你在屏幕左边缘和右边缘实测得到的ADC值screenXMin和screenXMax是屏幕像素边界通常就是0和319。Y轴同理但要注意方向很多触摸屏的Y ADC方向与屏幕Y坐标方向相反你需要先弄清楚是正方向还是反方向否则映射完会上下倒置。这个公式的本质就是“比例尺换算”把ADC值的区间宽度压缩或放大到像素区间。小学学的比例思想工程上会在触摸屏校准时反复用到。4.3 代码实现用C写一个可复用的映射类直接上浮点乘除法不是不行但在追求帧率的TFT界面上每帧都要算几十个触摸点浮点运算还是会感觉到迟滞。所以我用了定点数思路把除法改成乘法加移位class TouchMapper { public: TouchMapper(uint16_t rawXMin, uint16_t rawXMax, uint16_t rawYMin, uint16_t rawYMax, uint16_t screenW, uint16_t screenH) : _rawXMin(rawXMin), _screenW(screenW), _screenH(screenH) { // 预计算缩放因子以16.16定点表示 _scaleX ((uint32_t)screenW 16) / (rawXMax - rawXMin); _scaleY ((uint32_t)screenH 16) / (rawYMax - rawYMin); } uint16_t mapX(uint16_t rawX) { int32_t v (int32_t)rawX - _rawXMin; if (v 0) v 0; if (v 0xFFFF) v 0xFFFF; // 限幅 return (uint16_t)(((uint32_t)v * _scaleX) 16); } uint16_t mapY(uint16_t rawY) { int32_t v (int32_t)rawY - _rawYMin; if (v 0) v 0; uint32_t maxRawRange 0xFFFF; if (v (int32_t)maxRawRange) v maxRawRange; uint32_t mapped ((uint32_t)v * _scaleY) 16; // 如果Y方向反了就用 (screenH-1-mapped) return (uint16_t)mapped; } private: uint16_t _rawXMin, _rawYMin, _rawYRange; uint16_t _screenW, _screenH; uint32_t _scaleX, _scaleY; };一个小提醒上面的限幅边界要根据你实际ADC位数来定比如12位ADC最大4095就不需要0xFFFF的范围判断。我这里是随手写的示例你移植的时候务必改成与ADC位数匹配的数值。由于乘法是32位整数在STM32这类Cortex-M3上一条指令就能完成比浮点快得多。而在STC51这类8位单片机上32位乘法要拆成多条指令性能会差一些但触摸屏应用本身对刷新率要求不高仍然可用。4.4 实测中的非线性与温漂问题线性映射在范围比较小的时候表现良好但触摸屏本身存在制造误差和压力非线性边缘区域的偏差尤其明显。如果你是做高精度交互设备建议采用三点校准或五点校准在校准界面依次显示屏幕的边界点和中心点用户每点一次就记录一组ADC值然后做插值。三点校准用的是一阶平面拟合公式本质上比单纯的min/max缩放多补偿了旋转和比例误差。温度影响也不可忽视。我这块屏在常温下校准得很好放到室外低温环境一测ADC范围整体漂了大约6%到8%如果产品没有校准参数自动修正机制触摸位置就会整体偏移几个像素。对要求不高的界面几个像素偏移倒也能忍受但如果是点小按钮就很容易误触旁边的按钮这时候最好在固件里每隔一段时间做一次ADC范围自动校准在屏幕无触摸时采样背景噪声作为参考。5. 一次真实的崩溃排查access violation c00000055.1 现象C#上位机调用C DLL一调用就闪退这个崩溃虽然发生在PC端上位机但排查思路和单片机固件里的野指针问题如出一辙而且“c#调用c出现access violation c0000005”这个热搜出现频率非常高可见踩坑范围极其广泛。事件的主角是一个温控设备的上位机软件C#负责界面公司之前写好的USB通信和数据处理逻辑封装在一个C DLL里。第一次调用DLL里的某个接口界面直接闪退Windows事件日志里记录access violation c0000005。因为函数本身很简单接收一个int参数返回一个int所以一开始我完全没往“C#和C类型不匹配”的方向想。5.2 排查链路第一步核对调用约定我先从最简单的开始排查。C DLL里的函数默认是__cdecl调用约定而C#在默认情况下使用的是__stdcall也就是Win32 API那种标准调用方式。两种约定在参数传递顺序和栈清理责任上不同混用时最容易出现的现象就是首次调用能传进去但返回时栈被错乱清理随后系统直接报非法访问。修复很简单两种方式任选其一在C导出函数声明处显式加上__stdcall例如extern C __declspec(dllexport) int __stdcall Calculate(int value);或者在C#调用处加上CallingConvention指定[DllImport(device.dll, CallingConvention CallingConvention.Cdecl)] public static extern int Calculate(int value);强烈建议两条同时使用保持两边的约定完全一致。你无法控制所有队友在写导入声明时会不会漏写Convention。5.3 排查链路第二步检查结构体对齐和封装调用约定核对完之后跑通了几个简单参数但在传递一个自定义结构体时崩溃又回来了。这次的原因更隐蔽C结构体内部有int、short、char成员时编译器会在成员之间插入填充字节以对齐到4字节边界而C#侧默认布局是不管对齐的自然布局两边对同一块内存的尺寸和字段偏移量理解不一样。DLL往结构体里写数据时按照C的偏移量写C#按自己理解的偏移量读部分字段读到越界地址再一访问就炸了。排查方法是先在C#里打印结构体的Marshal.SizeOf()再在C侧打印sizeof(结构体)。如果两边尺寸不一样问题定位就完成了。解决办法是给C#结构体加上StructLayout和Pack让两者内存布局一致[StructLayout(LayoutKind.Sequential, Pack 1)] public struct DeviceInfo { public int id; public short type; public byte status; }5.4 排查链路第三步指针生命周期的坑连结构体也调通之后第三个问题出现在另一个返回字符串的函数上。C DLL里面返回了一个char数组的地址但那个数组是函数内部局部变量——函数一返回栈空间就释放了地址变成悬垂指针。C#拿到这个地址去转换字符串排查的时候一眼看到代码发现这个错误属于“C程序员之耻”级别的低级错误但它非常典型很多刚接触跨语言调用的人会犯。正确做法是让调用方传入缓冲区DLL往缓冲区里写数据__declspec(dllexport) int __stdcall GetVersion(char* buffer, int bufLen);C#侧申请一个足够大的byte[]固定住地址再传进去。这样内存所有权清晰谁分配谁释放避免了悬垂指针。5.5 映射到单片机固件的思考这一整套跨语言调用排查本质就是一句话内存是谁的谁来负责它的生命周期。单片机固件里的常见崩溃——比如数组越界后把栈写坏、RTOS任务栈开太小导致溢出、中断里访问了被释放的DMA缓冲区——全部属于同一类问题。C条例不用裸指针跨模块传临时对象永远让内存归属清晰结构体布局在跨模块间显式声明。这些经验放在固件开发里一样管用尤其是模块化程度高了之后驱动层给应用层返回指针时更要注明用户是不是需要负责释放。6. C在单片机里的语言细节陷阱6.1 字符串数组初始化与字符串转数组单片机上的字符串问题远比PC上绕。C标准里字符串字面量是const char数组但在Keil的C51模式下字符串默认存在代码段Flash。如果你写char *p hello;在51上这个p完全没意义因为字符串在Flash里不能通过普通数据指针访问。你必须用code关键字或者在配置里明确指定字符串存储区。真正建议的做法是const char msg[] hello;然后使用snprintf之类的带长度限制函数去拷贝而不是strcpy。在STM32这类RAM充足的环境里字符串转数组看上去没什么门槛但在RAM只有几百字节的51上一个无意间的strcpy就可能把临近的缓冲区全部冲掉这种问题用调试器查起来经常要耗掉半天。所以我的建议固件里能用整型绝不用字符串能定长数组绝不定长指针。6.2 结构体链表在嵌入式内存约束下怎么写C标准库的std::list自带动态内存分配底层依赖malloc/new。单片机裸机环境下默认堆可能非常小new几次之后返回空指针程序却完全没检查后面全是崩溃和悬垂操作。一个比较适合固件场景的方案是静态节点池templatetypename T, uint8_t MAX_NODES class StaticList { public: bool add(const T item) { if (_count MAX_NODES) return false; _items[_count] item; return true; } private: T _items[MAX_NODES]; uint8_t _count; };上面这个已经是简化版了但思路很清楚用数组预分配节点池把内存管理的复杂度挪到编译期。代价是MAX_NODES需要设计时预估好上限超过就add失败。对嵌入式来说这是好事——失败是显式的、可预期的而不是悄悄malloc失败后继续运行。6.3 单片机上的随机数伪随机与真随机的差距很多毕设项目需要“随机数”直接调用C库的rand()配合srand(time(NULL))这在PC上能工作但在单片机上经常翻车。因为单片机上没有统一的时间源你传的种子如果恒定不变rand()序列也一模一样。我踩过一次很直接的坑做一个抽奖动画每次开机后按按钮随机数结果都相同用户直接质疑程序有Bug。后来查出来就是srand传了个固定值。解决办法有几种看项目需求用计数器低字节做种子比如定时器里一个不断自增的变量开机后等待几毫秒再采一次值。这个方案最简单随机质量一般但演示项目足够。用ADC采样悬空引脚的噪声做种子。这个需要外接一段导线或者留一个感应焊盘效果比纯计数器好得多。对加密需求则必须外挂真随机数芯片模块靠主控采集噪声生成随机数而不是依赖软件算法。软件伪随机永远不可能成为密码学意义上的真随机。6.4 模板与lambda在Keil/GCC下的现状C模板在STM32的GCC工具链下已经可以放心用编译器优化之后代码体积和手写版本基本一致因为模板本质上还是“编译期生成代码”没有运行时开销。但Keil的C51环境下就不要指望太多C51的C支持其实是历史上遗留的有限子集模板支持质量参差不齐lambda表达式更是别想。如果你正在用STC、ATMEL的8位MCU又很想套用C泛型我建议慎重。也就是说C在32位MCU上能给你完整能力在8位MCU上给你的只是class这个壳更现代的语法可能水土不服。7. 开发环境的大实话VSCode、Redistributable与跨编译器7.1 VSCode配C/C环境为何总比想象中折腾“vscode配置c/c环境”这个搜索热度一直很高。VSCode本身只是个编辑器编译、链接、烧录全都交给外部工具链。很多新手在VSCode里写C装完C/C插件写了个hello_world.cpp按F5发现根本没反应因为缺tasks.json和launch.jsonVSCode不知道该调用哪个编译器也不知道编译后怎么调试。配置思路其实就三步装好编译器Windows下选MinGW-w64或者MSYS2Linux下用g。在tasks.json里配置编译命令建议用-c或者-o直接指定输出的文件路径。在launch.json里告诉调试器gdb程序路径和工作目录。如果你要编译51单片机代码VSCode只能当编辑器实际编译还得靠Keil C51或者SDCC命令行工具。SDCC挺好用配好环境变量之后可以在VSCode终端里一条命令编译并生成Hex文件。7.2 Visual C Redistributable与单片机开发有什么关系经常弹安装提示的“microsoft visual c redistributable”很多人以为这是编译器其实它是一组运行库DLL比如msvcp140.dll、vcruntime140.dll。C#调用C DLL或者运行用Visual Studio编译的桌面程序时就需要这些运行库否则系统会提示“找不到msvcp140.dll”程序直接无法启动。对单片机开发来说除非你连着一个上位机项目否则基本碰不到这层依赖因为你编译固件是直接烧到MCU里跑的不依赖PC运行库。但如果你是做“C上位机单片机下位机”联调的上位机换到一台没装过VS的电脑上就会突然遇到这个问题。解决方案一般是打包时把对应的Redistributable作为依赖装一遍或者用VS静态链接运行库让exe体积变大但免安装。7.3 同一份代码在不同编译器下的差异比想象中大单片机场景里Keil MDK、STM32CubeIDE自带GCC、IAR三家编译器对C标准的支持程度不同。比如在Keil里一些C11特性需要手动开启--cpp11IAR则对C14支持得相对完整GCC只要加在编译选项里写-stdc14就行。我公司的做法是代码里尽量只用C03加少量C11特性比如enum class和auto这种安全且编译器普遍支持的语法坚决不用C14后面的泛型lambda和constexpr大段展开。这样一套代码能在三个编译环境间无缝切换不用为了某个编译器的脾气改产品代码。8. 收个尾C用在单片机项目里到底值不值说句实在话值不值取决于你手上的单片机资源和团队的代码习惯。8位51单片机RAM只有128字节C能用的也就class封装和简单的重载带来的收益有限如果编译器支持又差我是有些犹豫的。扯下来不划算。32位MCU尤其是Cortex-M系列RAM和Flash都有一定余量C带来的模块化和状态机组织能力收益就很明显了至少我在STM32F103C8T6上做的几个项目重构之后代码行数减少了三分之一函数圈复杂度低了不少后面加需求时心情明显轻松。如果你正卡在“要不要把项目切成C”我给你一个最简单的起步方式第一步用C编译器编译现有的C代码把扩展名从.c改成.cpp处理掉编译报错让代码先跑起来。第二步挑一个你最头疼的模块比如按键管理或者显示驱动封装成类。不需要规划大架构不需要引入全套的面向对象设计模式就是一个一个类的养。你会发现大部分收益不是来自语法本身而是来自封装带来的思路整理。最后分享一个小技巧。我写单片机C代码时文档注释会在每个类的头部先写一句话这个类在哪一个中断里被调用哪些函数允许在中断上下文调用。这个习惯帮我避开过两次非常隐蔽的死锁问题。中断里的代码和主循环的代码共享同一个对象时记得临时关中断保护临界区否则看起来再完美的代码跑几天之后可能就会莫名卡死。这算是最真实的嵌入式C开发经验了。
返回列表