• 单片机到底是不是嵌入式?

    一定有很多人都听说过嵌入式和单片机,但在刚开始接触时,不知道大家有没有听说过嵌入式就是单片机这样的说法,其实嵌入式和单片机还是有区别的。单片机与嵌入式到底有什么关系? 下面我们就来说说嵌入式和单片机之间的联系和区别吧。 01 什么是单片机? 首先,我们来了解一下到底什么是单片机。 嵌入式系统的核心是嵌入式处理器。嵌入式处理器一般可以分为以下几种类型: 嵌入式微控制器MCU(Micro Control Unit) 嵌入式DSP处理器(Digital Signal Processor) 嵌入式微处理器MPU(Micro Processor Unit) 嵌入式片上系统SoC(System on Chip) 可编程片上系统SoPC(System on a Programmable Chip) 我们的单片机属于嵌入式微控制器MCU(Micro Control Unit) MCU内部集成ROM/RAM、总线逻辑、定时/计数器、看门狗、I/O、串口、A/D、D/A、FLASH等。典型代表如8051、8096、C8051F等。 单片机就是在一个芯片(Chip)上集成了CPU、SRAM、Flash及其他需要模块,在一个Chip上实现一个微型计算机系统,所以就叫Single Chip Microcomputer,也就是单片机了。 它其实就是一种集成电路芯片,是通过超大规模集成电路技术,将CPU、RAM、ROM、输入输出和中断系统、定时器/计数器等功能,塞进一块硅片上,变成一个超小型的计算机。 这么说来,单片机不就是一个嵌入式系统? 别急,我们往下看。 “单片机”其实是一种古老的叫法。在那个年代半导体工艺还在起步阶段,集成能力很差,往往是CPU一个芯片,SRAM一个芯片,Flash一个芯片,需要中断的话又得有个专门处理中断的芯片,所以一个完整可用的计算机系统是很多个芯片(Chip)做在一个PCB板上构成的。 不同的功能无法做进一个芯片(Chip),所以会有多片机。现在半导体技术早已非常发达,所以不存在多片机。但是,“单片机”的叫法却一直延用至今。 单片机技术从上世纪70年代末诞生,早期的时候是4位,后来发展为8位,16位,32位。它真正崛起,是在8位时代。8位单片机功能很强,被广泛应用于工业控制、仪器仪表、家电汽车等领域。 我们在研究单片机的时候,经常会听到一个词——51单片机。让我们来了解一下它究竟是什么。 51单片机,其实就是一系列单片机的统称。该系列单片机,兼容Intel 8031指令系统。它们的始祖,是Intel(英特尔)的8004单片机。 注意,51单片机并不全是英特尔公司产品。包括ATMEL(艾德梅尔)、Philips(飞利浦)、华邦Dallas(达拉斯)、Siemens(西门子)、STC(国产宏晶等公司,也有很多产品属于51单片机系列。 ATMEL公司的51单片机,AT89C51这是一个51单片机的开发板,中间那个芯片才是51单片机 51单片机曾经在很长时间里都是市面上最主流、应用最广泛的单片机,占据大量的市场份额。 51单片机其实放在现在毫无技术优势,是一种很老的技术。之所以它的生命力顽强,除了它曾经很流行之外,还有一个原因,就是英特尔公司彻底开放了51内核的版权。 所以,无论任何单位或个人,都可以毫无顾忌地使用51单片机,不用付费,也不用担心版权风险,所以很多学校也都在用这个。 此外,51单片机拥有雄厚的存量基础和群众基础。很多老项目都是用的51单片机,出于成本的考虑,有时候只能继续沿用51单片机的技术进行升级。 而且,很多老一辈的工程师,都精通51单片机开发技术。 所以,51单片机的生命力得以不断延续。 02 什么是嵌入式? 嵌入式系统是一种专用的计算机系统,作为装置或设备的一部分。通常,嵌入式系统是一个控制程序存储在ROM中的嵌入式处理器控制板。 事实上,所有带有数字接口的设备,如手表、微波炉、录像机、汽车等,都使用嵌入式系统,有些嵌入式系统还包含操作系统,但大多数嵌入式系统都是由单个程序实现整个控制逻辑。 从应用对象上加以定义,嵌入式系统是软件和硬件的综合体,还可以涵盖机械等附属装置。国内普遍认同的嵌入式系统定义为: 以应用为中心,以计算机技术为基础,软硬件可裁剪,适应应用系统对功能、可靠性、成本、体积、功耗等严格要求的专用计算机系统。 嵌入式系统具体应用于哪些“专用”方向呢? 举例如下: 办公自动化:打印机,复印机、传真机 军事及航天类产品:无人机、雷达、作战机器人 家电类产品:数字电视、扫地机器人、智能家电 医疗电子类产品:生化分析仪血液分析仪、CT 汽车电子类产品:引擎控制、安全系统、汽车导航与娱乐系统 网络通信类产品:通信类交换设备、网络设备 (交换机、路由器、网络安全) 通信与娱乐:手机、数码相机、音乐播放器、可穿戴电子产品、PSP游戏机 工业控制类产品:工控机交互式终端 (POS、ATM)、安全监控、数据采集与传输、仪器仪表 上述这些领域,都使用了嵌入式系统。这还只是冰山一角。 可以说,嵌入式系统完完全全地融入了我们,时刻影响着我们的工作和生活。 嵌入式系统,既然是一个计算机系统,那么肯定离不开硬件和软件。 一个嵌入式系统的典型架构如下: 这里最重要的就是嵌入式操作系统和嵌入式微处理器。 从硬件角度来看,嵌入式系统就是以处理器(CPU)为核心,依靠总线(Bus)进行连接的多模块系统: 其实大家不难看出和个人PC是一样的方式。 单片机是有清晰定义的,就是单个片(chip)上的计算机系统。而不同的单片机虽然配置不同,性能不同,厂家不同,甚至指令集和开发方式不同,但是都是在一个片上的完整的计算机系统,这个定义不会错。 而嵌入式就是个不清晰的定义了,并没有非常明确的关于“嵌入式”这个词的定义。他也不像单片机一样,是个确定的“物”的名字。 03 单片机是不是嵌入式? 那么单片机到底是不是嵌入式呢? 简单来说:是。 因为很多嵌入式产品中被嵌入的计算机系统就是单片机,譬如空调中嵌入的控制板其实核心就是个单片机。实际上大部分家电产品中嵌入的计算机系统都是单片机。 因为单片机足够简单便宜而且够用,所以使用单片机是最划算最适合的。 而单片机现在出货量最大的领域也就是家电产品了,当然未来IOT类的应用会越来越多,会成为单片机的很大的增量市场。 04 广义和狭义的嵌入式 嵌入式这个概念实际上很泛化,现在讲嵌入式这个词的人,可能想表达的意思并不相同。咱们上面讲的嵌入式的概念是嵌入式本来的定义,也就是所谓广义上的嵌入式。 而狭义的嵌入式,其实是“嵌入式linux系统”的简称。 这种狭义的嵌入式最初指的是运行了linux系统的嵌入式计算机系统。后来也包括运行了和linux同级别的其他嵌入式系统(譬如WinCE、Vxworks、Android等)的计算机。 看过上面的介绍之后你就知道到底单片机是不是嵌入式了,其实这两者之间的联系有很深,总之,不管你是准备学习嵌入式或是单片机,都要自己想好了再做决定。 05 嵌入式和单片机的区别 说到这里,我们来看看,嵌入式和单片机的区别到底是什么。 从前文的介绍来看,嵌入式系统是一个大类,单片机是其中一个重要的子类。嵌式系统像是一个完整的计算机,而单片机更像是一个没有外设的计算机。 以前单片机包括的东西并不算多,两者的硬件区别较为明显。 但是,随着半导体技术的突飞猛进,现在各种硬件功能都能被做进单片机之中。所以,嵌入式系统和单片机之间的硬件区别越来越小,分界线也越来越模糊。 于是,人们倾向于在软件上进行区分。 从软件上,行业里经常把芯片中不带MMU(memory management unit,内存管理单元)从而不支持虚拟地址,只能裸奔或运行RTOS(实时操作系统,例如ucos、华为LiteOS、RT-Thread、freertos等)的system,叫做单片机(如STM32、NXP LPC系列、NXP imxRT1052系列等)。 同时,把芯片自带MMU可以支持虚拟地址,能够跑Linux、Vxworks、WinCE、Android这样的“高级”操作系统的system,叫做嵌入式。 在某些时候,单片机本身已经足够强大,可以作为嵌入式系统使用。它的成本更低,开发和维护的难度相对较小,尤其是针对一些针对性更强的应用。而嵌入式系统理论上性能更强,应用更广泛,但复杂度高,开发难度大。 06 我们为什么要学习嵌入式和单片机 今天我也只是给大家简单地介绍了一下单片机和嵌入式以及他们之间的关系和区别,虽然嵌入式系统已经有30多年的历史,但其实一直隐藏在背后的,自从物联网上升为国家战略后,嵌入式系统也渐渐从后台走到前台。 嵌入式和单片机并不是纯“硬件”类方向。如果你想学好嵌入式和单片机,只懂数字电路和微机接口这样的硬件知识是不够的,你更需要学习的,是汇编、C/C++语言、数据结构和算法知识。拥有软硬结合的能力,远远比单纯掌握某种程序开发语言更有价值。 其次,嵌入式和单片机拥有广泛的应用场景,在各个领域都有项目需求和人才需求。而且我们国家现在正在大力发展芯片产业,也会带动嵌入式人才的就业,提升待遇。 随着5G建设的深入,整个社会正在向“万物互联”的方向变革。 物联网技术也将迎来前所未有的历史机遇。嵌入式和单片机技术是物联网技术的重要组成部分,也将进入快速发展的时代。 技术越难,过程越苦,越有利于构建竞争壁垒。大学里很多同学都热衷于学习各种编程语言,往往忽视了这一块,可以说在嵌入式开发这一块的人才我们国家还是比较欠缺的。因此,我觉得大家非常值得投入时间去学习嵌入式开发的技能。原文:https://www.zhihu.com/question/315310041/answer/2179945564

    01-03 92浏览
  • 为什么​嵌入式开发全局变量要越少越好?

    嵌入式开发,特别是单片机os-less的程序,最易范的错误是全局变量满天飞。 这个现象在早期汇编转型过来的程序员以及初学者中常见,这帮家伙几乎把全局变量当作函数形参来用。 在.h文档里面定义许多杂乱的结构体,extern一堆令人头皮发麻的全局变量,然后再这个模块里边赋值123,那个模块里边判断123分支决定做什么。 每当看到这种程序,我总要戚眉变脸而后拍桌怒喝。没错,就是怒喝。 不否认全局变量的重要性,但我认为要十分谨慎地使用它,滥用全局变量会带来其它更为严重的结构性系统问题。 为什么全局变量要越少越好? 它会造成不必要的常量频繁使用,特别当这个常量没有用宏定义“正名”时,代码阅读起来将万分吃力。 它会导致软件分层的不合理,全局变量相当于一条快捷通道,它容易使程序员模糊了“设备层”和“应用层”之间的边界。写出来的底层程序容易自作多情地关注起上层的应用。 这在软件系统的构建初期的确效率很高,功能调试进度一日千里,但到了后期往往bug一堆,处处“补丁”,雷区遍布。说是度日如年举步维艰也不为过。 由于软件的分层不合理,到了后期维护,哪怕仅是增加修改删除小功能,往往要从上到下掘地三尺地修改,涉及大多数模块, 而原有的代码注释却忘了更新修改,这个时候,交给后来维护者的系统会越来越像一个“泥潭”,注释的唯一作用只是使泥潭上方再加一些迷烟瘴气。 全局变量大量使用,少不了有些变量流连忘返于中断与主回圈程序之间。 这个时候如果处理不当,系统的bug就是随机出现的,无规律的,这时候初步显示出病入膏肓的特征来了,没有大牛来力挽狂澜,注定慢性死亡。 无需多言,您已经成功得到一个畸形的系统,它处于一个神秘的稳定状态! 你看着这台机器,机器也看着你,相对无言,心中发毛。你不确定它什么时候会崩溃,也不晓得下一次投诉什么时候道理。 全局变量大量使用有什么后果? “老人”气昂昂,因为系统离不开他,所有“雷区”只有他了然于心。当出现紧急的bug时,只有他能够搞定。你不但不能辞退他,还要给他加薪。 新人见光死,但凡招聘来维护这个系统的,除了改出更多的bug外,基本上一个月内就走人,到了外面还宣扬这个公司的软件质量有够差够烂。 随着产品的后续升级,几个月没有接触这个系统的原创者会发现,很多雷区他本人也忘记了,于是每次的产品升级维护周期越来越长, 因为修改一个功能会冒出很多bug,而按下一个bug,会弹出其他更多的bug。在这期间,又会产生更多的全局变量。 终于有一天他告诉老板,不行啦不行啦,资源不够了,ram或者flash空间太小了,升级升级。 客户投诉不断,售后也快崩溃了,业务员也不敢推荐此产品了,市场份额越来越小,公司形象越来越糟糕。   要问对策,只有两个原则 能不用全局变量尽量不用,我想除了系统状态和控制参数、通信处理和一些需要效率的模块,其他的基本可以靠合理的软件分层和编程技巧来解决。 如果不可避免需要用到,那能藏多深就藏多深。 如果只有某.c文件用,就static到该文件中,顺便把结构体定义也收进来; 如果只有一个函数用,那就static到函数里面去; 如果非要开放出去让人读取,那就用函数return出去,这样就是只读属性了; 如果非要遭人蹂躏赋值,好吧,我开放函数接口让你传参赋值; 实在非要extern侵犯我,我还可以严格控制包含我.h档的对象,而不是放到公共的includes.h中被人围观,丢人现眼。 如此,你可明白我对全局变量的感悟有多深刻,悲催的我,已经把当年那些“老人”交给我维护的那些案子加班全部重新翻写了。 最后补充 全局变量是不可避免要用到的,每一个设备底层几乎都需要它来记录当前状态,控制时序,起承转合。但是尽量不要用来传递参数,这个很忌讳的。 尽量把变量的作用范围控制在使用它的模块里面,如果其他模块要访问,就开个读或写函数接口出来,严格控制访问范围。 这一点,C++的private属性就是这么干的,这对将来程序的调试也很有好处。 C语言之所以有++版本,很大原因就是为了控制它的灵活性,要说面向对象的思想,C语言早已有之,亦可实现。 当一个模块里面的全局变量超过3个(含)时,就用结构体包起来吧,要归0便一起归0,省得丢三落四的。 在函数里面开个静态的全局变量,全局数组,是不占用栈空间的,只是有些编译器对于大块的全局数组,会放到和一般变量不同的地址区。 若是在keil C51,因为是静态编译,栈爆掉了会报警,所以大可以尽情驰骋,注意交通规则就是了。 单片机的os-less系统中,只有栈没有堆的用法,那些默认对堆分配空间的“startup.s”,可以大胆的把堆空间干掉。 程序模型?如何分析抽象出来呢,从哪个角度进行模型构建呢?很愿意聆听网友的意见。 本人一直以来都是从两个角度分析系统,事件--状态机迁移图 和 数据流图,前者分析控制流向,完善UI,后者可知晓系统数据的缘起缘灭。 这些理论,院校的《软件工程》教材都有,大家不妨借鉴下。只不过那些理论,终究是起源于大型系统软件管理的,牛刀杀鸡,还是要裁剪一下的。 

    01-03 71浏览
  • Bootloader的见鬼故障

    【前面的话】在近几年的嵌入式社区中,流传着不少关于面相Cortex-M的Bootloader科普文章,借助这些文章,一些较为经典的代码片断和技巧得到了广泛的传播。  在从Bootloader跳转到用户APP的过程中,使用函数指针而非传统的汇编代码则成了一个家喻户晓的小技巧。相信类似下面 JumpToApp() 函数,你一定不会感到陌生: typedef void (*pFunction)(void); void JumpToApp(uint32_t addr){ pFunction Jump_To_Application; __IO uint32_t StackAddr; __IO uint32_t ResetVector; __IO uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); }} 为了读懂这段代码,需要一些从事Cortex-M开发所需的“热知识”: 向量表是一个由 32bit 数据构成的数组 数组的第一个元素是 uintptr_t 类型的指针,保存着复位后主栈顶指针(MSP)的初始值。 从数组第二个元素开始,保存的是 (void (*)(void)) 类型的异常处理程序地址(BIT0固定为1,表示异常处理程序使用Thumb指令集进行编码) 数组的第二个元素保存的是复位异常处理程序的地址(Reset_Handler) 从理论上说,要想保证APP能正常执行,Bootloader通常要在跳转前“隐藏自己存在过的事实”——需要“对房间进行适度的清理”,并模拟芯片硬件的一些行为——假装芯片复位后是直接从APP开始执行的。 总结来说,Bootloader在跳转到App之前需要做两件事: 1. 清理房间——仿佛Bootloader从未执行过一样 2. 模拟处理器的硬件的一些复位行为——假装芯片从复位开始就直接从APP开始执行 一般来说,做到上述两点,就可以实现App将Bootloader视作黑盒子的效果,从而带来极高的兼容性。甚至在App注入了“跳床(trumpline)”的情况下,实现App既可以独立开发、调试和运行,也可以不经修改的与Bootloader一起工作的奇效。 如何在App中加入“跳床(trumpline)”值得专门再写一篇独立的文章,不是本文所需关注的重点,请允许我暂且略过。 这里,“清理房间”的步骤与Bootloader具体“弄脏了什么”(或者说使用了什么资源)有关;而“模拟处理器硬件的一些复位行为”就较为简单和具体:即,从Bootloader跳转到App前的最后两个步骤为: 从APP的向量表中读取MSP的初始值并以此来初始化MSP寄存器; 从APP的向量表中读取Reset_Handler的值,并跳转到其中去执行——完成从Bootloader到APP的权利交接。 结合前面的例子代码,值得我们关注的部分是: 1. 使用自定义的函数指针类型 pFunction 定义一个局部变量: pFunction Jump_To_Application; 2. 根据向量表的首地址 addr 读取第一个元素——作为MSP的初始值暂时保存在局部变量 StackAddr 中: StackAddr = *(__IO uint32_t*)addr; 3. 根据向量表的首地址 addr 读取第二个元素——将Reset_Handler的首地址保存到局部变量 ResetVector 中: ResetVector = *(__IO uint32_t *)(addr + 4); 4. 设置栈顶指针MSP寄存器: __set_MSP(StackAddr); 5. 通过函数指针完成从Bootloader到App的跳转: Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); 其实,无论具体的代码如何,只要实现步骤与上述类似,就存在一个隐藏较深的漏洞,而漏洞的“触发与否”则完全“看脸”——简单来说: 只要你是按照上述方法来实现从Bootloader到App的跳转的,那么就一定存在问题——而“似乎可以正常工作”就只是你运气较好,或者“由此引发的问题暂时未能引发注意”罢了。 在你试图争辩“老子代码已经量产了也没有什么不妥”之前,我们先来看看漏洞的原理是什么——在知其所以然后,如何评估风险就是你们自己的事情了。 【C语言基础设施是什么】 嵌入式系统的信息安全(Security)建立在基础设施安全(Safety)的基础之上。 由于“确保信息安全的很多机制”本质上是一套建立在“基础设施能够正常工作”这一前提之上的规则和逻辑,因此很多针对信息安全的攻击往往会绕开信息安全的“马奇诺防线”,转而攻击基础设施。芯片数字逻辑的基础设施是时钟源、供电、总线时序、复位时序等等,因此,针对硬件基础设施的攻击通常也就是针对时钟源、电源、总线时序和复位时序的攻击。此时,好奇的小伙伴会产生疑问:固件一般由C语言进行编写,那么C语言所依赖的基础设施又是什么呢? 对C语言编译器来说,栈的作用是无可替代的: 函数调用 函数间的参数传递 分配局部变量 暂时保存通用寄存器中的内容 …… 可以说,离开了栈C语言寸步难行。因此对很多芯片来说,复位后为了执行用户使用C语言编译的代码,第一个步骤就是要实现栈的初始化。 作为一个有趣的“冷知识”,Cortex-M在宣传中一直强调自己“支持完全使用C语言进行开发”,这让很多人“丈二和尚摸不着头脑”甚至觉得“非常可笑”——因为这年月连51都支持用户使用C语言进行开发了,你这里说的“Cortex-M支持使用C语言进行开发”有什么意义呢? 其实门道就在这里: 由于Cortex-M处理器会在复位时由硬件完成对C语言基础设施(也就是栈顶指针MSP)的初始化,因此无论是理论上还是实践中,从复位异常处理程序Reset_Handler开始用户就可以完全可以使用C语言进行开发了,而整个启动代码(startup)也可以全然不涉及任何汇编; 由于Cortex-M的向量表是一个完全由 32位整数(uintptr_t)构成的数组——保存的都是地址而非具体代码,可以使用C语言的数据结构直接进行描述——因此也完全不需要汇编语言的介入。 这种从复位一开始就完全不需要汇编介入的友好环境才是Cortex-M声称自己“支持完全使用C语言进行开发”的真实意义和底气。从这一角度出发,只要某个芯片架构复位后必须要通过软件来初始化栈顶指针,就不符合“从出生的那一刻就可以使用C语言”的基本要求。 【C语言编译器的约定】 栈对C语言来说如此重要,以至于编译器一直有一条默认的约定,即: 栈必须完全交由C语言编译器进行管理(或者用户对栈的操作必须符合对应平台所提供的调用规约,比如Arm的AAPCS规约)。 简而言之,如果你“偷偷摸摸”的修改了栈顶指针,C语言编译器是会“假装”完全不知道的,而此时所产生的后果C语言编译器会默认自己完全不用负责。 回头再看这段代码: StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); 虽然我们觉得自己“正大光明”的使用了 __set_MSP() 来修改了栈顶指针,但它实际上是一段C语言编译器并不理解其具体功能的在线汇编——在编译器看来,无论是谁提供的 __set_MSP(),只要是在线汇编,这就算是用户代码——是编译器管不到的地带。 /** \brief Set Priority Mask \details Assigns the given value to the Priority Mask Register. \param [in] priMask Priority Mask */__STATIC_FORCEINLINE void __set_PRIMASK(uint32_t priMask){ __ASM volatile ("MSR primask, %0" : : "r" (priMask) : "memory");} 或者说:C语言编译器一般情况下会默认你“无论如何都不会修改栈顶指针”——它不仅管不着,也不想管。 从这点来看,上述代码的确打破了这份约定。即便如此,很多小伙伴会心理倔强的认为:我就这么改了,怎么DE了吧?! 【问题的分析】 从原理上说,开篇那个典型的Bootloader跳转代码所存在的问题已经昭然若揭: typedef void (*pFunction)(void); void JumpToApp(uint32_t addr){ pFunction Jump_To_Application; __IO uint32_t StackAddr; __IO uint32_t ResetVector; __IO uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); }} 我们不妨结合上述代码反汇编的结果进行深入解析: AREA ||i.JumpToApp||, CODE, READONLY, ALIGN=2 JumpToApp PROC000000 b082 SUB sp,sp,#8000002 4909 LDR r1,|L2.40|000004 9100 STR r1,[sp,#0]000006 6802 LDR r2,[r0,#0]000008 400a ANDS r2,r2,r100000a 2101 MOVS r1,#100000c 0749 LSLS r1,r1,#2900000e 428a CMP r2,r1000010 d107 BNE |L2.34|000012  6801              LDR      r1,[r0,#0]000014 9100 STR r1,[sp,#0]000016 6840 LDR r0,[r0,#4]000018 f3818808 MSR MSP,r100001c 9001 STR r0,[sp,#4]00001e b002 ADD sp,sp,#8000020 4700 BX r0 |L2.34|000022 b002 ADD sp,sp,#8000024 4770 BX lr ENDP 000026 0000 DCW 0x0000 |L2.40| DCD 0x2fff0000 注意这里,StackAddr、ResetVector是两个局部变量,由编译器在栈中进行分配。汇编指令将SP指针向栈底挪动8个字节就是这个意思: 000000 b082 SUB sp,sp,#8 虽然 JumpMask 也是局部变量,但编译器根据自己判断认为它“命不久矣”,因此直接将它分配到了通用寄存器r2中,并配合r1和sp完成了后续运算。这里: __IO uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { ... } 对应: 000002 4909 LDR r1,|L2.40|000004 9100 STR r1,[sp,#0]000006 6802 LDR r2,[r0,#0]000008 400a ANDS r2,r2,r100000a 2101 MOVS r1,#100000c 0749 LSLS r1,r1,#2900000e 428a CMP r2,r1000010 d107 BNE |L2.34|...|L2.34|000022 b002 ADD sp,sp,#8000024 4770 BX lrENDP 000026 0000 DCW 0x0000|L2.40|DCD 0x2fff0000 考虑到JumpMask的内容与本文无关,不妨暂且跳过。 接下来就是重头戏了: 编译器按照用户的指示读取栈顶指针MSP的初始值,并保存在StackAddr中: StackAddr = *(__IO uint32_t*)addr; 对应的汇编是: 000012 6801 LDR r1,[r0,#0]000014 9100 STR r1,[sp,#0] 根据Arm的AAPCS调用规约,编译器在调用函数时会使用R0~R3来传递前4个符合条件的参数(这里的条件可以简单理解为每个参数的宽度要小于等于32bit)。根据函数原型 void JumpToApp(uint32_t addr); 可知,r0 中保存的就是形参 addr 的值。所以第一句汇编的意思就是:根据 (addr + 0)作为地址读取一个uint32_t型的数据保存到r1中。 第二句汇编中,栈顶指针sp此时实际上指向局部变量 StackAddr,因此其含义就是将通用寄存器r1中的值保存到局部变量 StackAddr 中。 对于局部变量 ResetVector 的读取操作,编译器的处理如出一辙: ResetVector = *(__IO uint32_t *)(addr + 4); 对应: 000016  6840              LDR      r0,[r0,#4]00001c 9001 STR r0,[sp,#4] 其实就是从 (addr + 4) 的位置读取 32bit 整数,然后保存到r0里,并随即保存到sp所指向的局部变量 ResetVector 中。到这里,细心地小伙伴会立即跳起来说“不对啊,原文不是这样的!”。是的,这也是最有趣的地方。实际的汇编原文如下: 000016 6840 LDR r0,[r0,#4]000018 f3818808 MSR MSP,r100001c 9001 STR r0,[sp,#4] 作为提醒,它对应的C代码如下: ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); 后面的 __set_MSP(StackAddr) 所对应的汇编代码 MSR MSR,r1 居然插入到了ResetVector赋值语句的中间?! “C语言编译器这么自由的么?” “在我使用sp之前把栈顶指针更新了?!” 先别激动,还记得我们和C语言编译器之间的约定么?C语言编译器默认我们在任何时候都不应该修改栈顶指针。因此在他看来,“你 MSR 指令操作的是r1,关我sp和r0啥事”?“我就算随意更改顺序应该对你一毛钱影响都没有!(因为我不关心、也没法知道用户线汇编语句的具体效果,因此我只关心涉事的通用寄存器是否存在冲突)” 上述“骚操作”的后果是:保存在r0中的Reset_Handler地址值被保存到了新栈中(MSP + 4)的位置。这立即带来两个潜在后果: 由于MSP指向的是栈存储器的末尾(栈是从数值较大的地址向数值较小的地址生长),因此 (MSP+4)实际上已经超出栈的合法范围了。 这一操作与其说是会覆盖栈后续的存储空间,倒不如说风险主要体现在BusFault上——因为相当一部分人习惯将栈放到SRAM的最末尾,而MSP+4直接超出SRAM的有效范围。 我们以为的ResetVector其实已经不在原本C编译器所安排的地址上了。 精彩的还在后面: Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); 对应的翻译是: 00001e b002 ADD sp,sp,#8000020 4700 BX r0 通过前面的分析,我们知道,此时r0中保存的是Reset_Handler的地址,因此 BX r0 能够成功完成从Bootloader到APP的跳转——也许你会松一口气——好像局部变量ResetVector的错位也没引起严重的后果嘛。 看似如此,但真正吓人的是C语言编译器随后对局部变量的释放: 00001e b002 ADD sp,sp,#8 它与一开始局部变量的分配形成呼应: 000000 b082 SUB sp,sp,#8...00001e b002 ADD sp,sp,#8 好借好还,再借不难。但此sp非彼sp了呀! 这里由于JumpToApp没有加上__NO_RETURN的修饰,因此C编译器并不知道这个函数是有去无回的,因此仍然会像往常一样在函数退出时释放局部变量。 就像刚才分析的那样:由于MSP指向的是栈存储器的末尾(栈是从数值较大的地址向数值较小的地址生长),因此 (MSP+8)实际上已经超出栈存储空间的合法范围了。 考虑到相当一部分人习惯将栈放到SRAM的最末尾,而MSP+8直接超出SRAM的有效范围,即便刚跳转到APP的时候还不会有事,但凡APP用了任何压栈操作,(无论是BusFault还是地址空间绕回)就很有可能产生灾难性的后果。 【宏观分析】 就事论事的讲,单从汇编分析来看,上述代码所产生的风险似乎是可控的,甚至某些人会觉得可以“忽略不计”。但最可怕的也就在这里,原因如下: 从原理上说,将关键信息保存在依赖栈的局部变量中,然后在编译器不知情的情况下替换了栈所在的位置,此后只要产生对相关局部变量的访问就有可能出现“刻舟求剑”的数据错误。这种问题是“系统性的”、“原理性的”。 (此图由GorgonMeducer借助GPT4进行一系列关键词调校、配上台词后获得) 不同编译器、同一编译器的不同版本、同一版本的不同优化选项都有可能对同一段C语言代码产生不同的编译结果,因此哪怕我们经过上述分析得出某一段汇编代码似乎不会产生特别严重的后果,在严谨的工程实践上,这也只能算做是“侥幸”,是埋下了一颗不知道什么时候以什么方式引爆的定时炸弹。 根据用户Bootloader代码在修改 MSP 前后对局部变量的使用情况不同、考虑到用户APP行为的不确定性、由上述缺陷代码所产生的Bootloader与APP之间配合问题的组合多种多样、由于涉及到用户栈顶指针位置的不确定性以及新的栈存储器空间中内容的随机性,最终体现出来的现象也是完全随机的。用人话说就是,经常性的“活见鬼” 【解决方案】 既然我们知道不能对上述缺陷代码抱有侥幸心理,该如何妥善解决呢?第一个思路:既然问题是由栈导致的,那么直接让编译器用通用寄存器来保存关键局部变量不就行了?修改代码为: typedef void (*pFunction)(void); void JumpToApp(uint32_t addr){ pFunction Jump_To_Application; register uint32_t StackAddr; register uint32_t ResetVector; register uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); }} 相同编译环境下得出的结果为: AREA ||i.JumpToApp||, CODE, READONLY, ALIGN=2 JumpToApp PROC 000002 6801 LDR r1,[r0,#0]000004 4011 ANDS r1,r1,r2000006 2201 MOVS r2,#1000008 0752 LSLS r2,r2,#2900000a 4291 CMP r1,r200000c  d104              BNE |L2.24| 00000e 6801 LDR r1,[r0,#0]000010 6840 LDR r0,[r0,#4]000012 f3818808          MSR      MSP,r1 000016 4700 BX r0 |L2.24|000018 4770 BX       lr ENDP 00001a 0000 DCW 0x0000 |L2.28| DCD 0x2fff0000 可见,上述汇编中半个 sp 的影子都没看到,问题算是得到了解决。 然而,需要注意的是 register 关键字对编译器来说只是一个“建议”,它听不听你的还不一定。加之上述例子代码本身相当简单,涉及到的局部变量数量有限,因此问题似乎得到了解决。 倘若编译器发现你大量使用 register 关键字导致实际可用的通用寄存器数量入不敷出,大概率还是会用栈来进行过渡的——此时,哪些局部变量用栈,哪些用通用寄存器就完全看编译器的心情了。 进一步的,不同编译器、不同版本、不同优化选项又会带来大量不可控的变数。 因此就算使用 register 修饰关键局部变量的方法可以救一时之疾(“只怪老板催我催得紧,莫怪我走后洪水滔天”),也算不得妥当。 第二个思路:既然问题出在局部变量上,我用静态(或者全局)变量不就可以了?修改源代码为: #include "cmsis_compiler.h" typedef void (*pFunction)(void); __NO_RETURNvoid JumpToApp(uint32_t addr){ pFunction Jump_To_Application; static uint32_t StackAddr; static uint32_t ResetVector; register uint32_t JumpMask; JumpMask = ~((MCU_SIZE-1)|0xD000FFFF); if (((*(__IO uint32_t *)addr) & JumpMask ) == 0x20000000) //�ж�SPָ��λ�� { StackAddr = *(__IO uint32_t*)addr; ResetVector = *(__IO uint32_t *)(addr + 4); __set_MSP(StackAddr); Jump_To_Application = (pFunction)ResetVector; Jump_To_Application(); }} 这种方法看似稳如老狗,实际效果可能也不差,但还是存在隐患,因为它“没有完全杜绝编译器会使用栈的情况”,只要我们还会通过 __set_MSP() 在C语言编译器不知道的情况下更新栈顶指针,风险自始至终都是存在的。 对某些连warning都要全数消灭的团队来说,上述方案多半也是不可容忍的。 第三个思路:完全用汇编来处理从Bootloader到App的最后步骤。对此我只想说:稳定可靠,正解。 只不过需要注意的是:这里整个函数都需要用纯汇编打造,而不只是在C函数内容使用在线汇编。 原因很简单:既然我们已经下定决心要追求极端确定性,就不应该使用线汇编这种与C语言存在某些“暧昧交互”的方式——因为它仍然会引入一些意想不到的不确定性。 本着一不做二不休的态度,完全使用汇编代码来编写跳转代码才是万全之策。 【说在后面的话】 在使用栈的情况下,on-fly 的修改栈顶指针就好比在飞行途中更换引擎——不是不行,只是要求有亿点点高。 我在微信群中帮读者分析各类Bootloader的见鬼故障时,经常在大费周章的一通分析和调试后,发现问题的罪魁祸首就是跳转代码。可怕的是,几乎每个故障的具体现象都各不相同,表现出的随机性也常常让人怀疑是不是硬件本身存在问题,亦或是产品工作现场的电磁环境较为恶劣。最要命的当数那种“偶尔出现”而复现条件颇为玄学的情形,甚至在办公室环境下完全无法重现的也大有人在。同样的问题出的多了,我几乎在每次帮人调试Bootloader时都会习惯性的先要求检查跳转代码——虽然不会每次都能猜个正着,但也有个恐怖的十之七八。这也许是某种幸存者偏差吧——毕竟大部分普通问题大家自己总能解决,到我这里的多半就是“驱鬼”了。见得多了,我突然发现,出问题的代码大多使用函数指针来实现跳转——而用局部变量来保存函数指针又成了大家自然而然的选择。加之此前很多文章都曾大规模科普上述技巧,甚至是直接包含一些存在缺陷的Bootloader范例代码,实际受影响的范围真是“细思恐极”。特此撰文,为您解惑。 

    01-03 13浏览
  • 脑洞有多大,MCU就能玩得有多花

    都说MCU本身不算什么高级东西,在MCU开发过程中,需要按照一定的标准化来执行,比如对变量,函数的定义,要确定他的生命周期,调用范围,访问条件等;常用的通信协议读写的协议往往应该抽象化,规定固定的输入输出,方便产品移植。 但实际上,很多时候,针对同一个需求其实有多种实现方案,但总有一个最优解。所以在这个过程中,总会有一些“脑洞大开”的操作,为人提供很多思路,今天就举几个例子给大家作为参考。 那些很惊艳的用法 当需要通过串口接收一串不定长数据时,可以使用串口空闲中断;这样就可以避免每接收到一个字符就需要进入中断进行处理,可以减少程序进入中断次数从而提高效率。 当需要测量一个波形的频率时,很多人会选择外部中断,其实通过定时器的外部时钟输入计数波形边沿,然后定时读取计数值计算频率的方式可以大大减少中断触发频率,提高程序执行效率。 在处理复杂的多任务场景时,可以利用实时操作系统(RTOS)来管理任务调度,提高系统的响应性和资源利用率。 对于需要低功耗运行的场景,可以采用动态电压频率调整(DVFS)技术,根据系统负载实时调整 MCU 的工作电压和频率,以降低功耗。 在进行数据存储时,采用闪存的磨损均衡算法,延长闪存的使用寿命。 利用硬件加密模块(如 AES 加密引擎)来保障数据的安全性和保密性,而不是通过软件实现加密,提高加密效率和安全性。 对于传感器数据的处理,采用数字滤波算法(如卡尔曼滤波),提高数据的准确性和稳定性。 当需要与多个设备进行通信时,采用总线仲裁机制和优先级设置,确保通信的高效和稳定。 在进行电源管理时,通过监测电源电压和电流,实现智能的电源管理策略,例如在低电量时进入低功耗模式。 对于实时性要求极高的控制任务,采用硬件直接触发中断,而不是通过软件轮询,减少响应延迟。 在单片机上跑的任何非线性系统的动态控制,都是高级用法。 用单片机去实现某种特殊的运动控制,赚很多钱,就是高级用法。 GPIO模拟一切 名为ShiinaKaze的网友,就非常“勇”,做了一个很折磨的事。 他用STM32F1利用GPIO模拟摄像头接口驱动OV2640摄像头模块。他表示,这是一个很折磨人的过程,我最多优化到了 1.5 FPSQ,所以选型一定要选好,不要折磨自己。设备采用STM32F103C8T6,OV2640,实现效果如下: OV2640实际时序图: 这个项目难点在于: 1.SCCB 模拟:SCCB 是12C-bus 的改版,主要是 OV2640 模块没有上拉电阻,无法进行通信,花了好长时间才发现这个问题; 2.并行接口的模拟:如果使用 IO 模拟的话,只能达到1FPS,但是使用了 Timer 和 DMA,就可以达到 1.5~2 FPS。 关于 image sensor 的数据接收和处理的问题背景:现有 ov2640 image sensor,接口为 DCMI(并行接口)问题:现有 STM32H7 想获取 OV2640 的 mjpeg 流数据,并通过传输数据到 PC 软件 1.采用 USART 还是 USB? 2.接收数据选择哪种中断,Line interrupt 还是 Frame interrupt ? 3.DCMI 通过 DMA 将数据转到 RAM 中的 Buffer,那么 Buffer 该如何设计,是设置一块大的连续 buffer?还是需要做一个 ring buffer,避免数据覆盖和数据顺乱? 4.触发中断后,是否关闭 DCMI 和 DMA ? 嵌入式软件架构挺重要的,特别是大型项目。这是 STM32 的软件架构,不知道各位还有没有其他架构。 有网友吐槽,你要是在学校,我敬你是条汉子,你要是在工作岗位上干这鸟事,那你们的架构也太坏了。而他也表示——“我错了,再也不模拟了。” 关于MCU不一样的观点 虽然如此,很多人还是认为,MCU不高级,使用单片机也不高级。高级的内容都是可以发论文的,使用单片机发不了论文。但使用单片机解决指定的任务,这很高级。 尤其是上面所说的一些例子,确实是MCU外设的一些高端玩法。只不过,这些机制可能只是一种标准用法。名为lion187的网友就表示,毕竟许多硬件机制有实际需求后才添加进来的,比如接收不定长数据,最初没有超时中断的情况下只能软件实现,极大的浪费了CPU的效率,所以才设计了超时中断来减少软件工作量,进而形成了一种标准使用方法。 当然,这也是芯片设计和制造工艺的提升带来的红利,早期芯片设计和工艺无法满足复杂外设电路时,谁也不敢会去想用硬件来实现这么复杂的功能,任何产品的开发,都离不开具体业务需求,MCU也不例外, 对产品来说,MCU外设的驱动只是完成开发的基本要素,更多的工作是围绕着业务逻辑展开的应用程序的开发。这时候数据结构与算法,各种控制算法和数值计算方法,设计模式,软件工程和设计理念成了高级的东西。 比如说,Linux 内核中的各驱动子系统的设计,设备对象和驱动对象这些沿用了 C++ 面向对象编程的思路,其实也可以沿用到 MCU的开发中,将设备与驱动分离,就可以使用同一套驱动算法来实现同类设备的不同驱动方法, 比如:同一个 UART 驱动可以根据配置的不同来驱动 UARTO,也可以驱动 UART1,而且波特率也可以不同(只要为 UART 类创建不同的实例对象就可以了,用 C 语言就行),这就是 C++ 中方法与属性分离带来的好处。 同样在业务应用部分,单件模式、工厂模式等设计模式,状态机模型的使用也会给开发带来很多便利,使系统结构清晰,有效减少Bug数量,且易于维护和扩展。 当然,也有人认为,论高级还得是FPGA。就比如AMD(赛灵思)的ZYNQ,当你需要通过串口接收一串不定长数据时,可以直接用Programmable Logic部分写一个专用的,最终结果放到DRAM里,发个信号通知ARM处理器来读就好了;当你需要测量一个波形的频率时,可以直接用Programmable Logic部分写一个专用的,实时不间断测量。这就很高级。 所以,对此你有什么看法,你有什么很“高级”的用法想要分享? 

    01-03 43浏览
  • 基于STM32的室内温湿度采集控制系统

    Proteus仿真——《基于STM32的室内温湿度采集控制系统》

    01-03 54浏览
  • STM32最小系统板电路知识学习

    STM32最小系统板电路知识学习 单片机最小系统是指用最少的电路组成单片机可以工作的系统,通常最小系统包含:电源电路、时钟电路、复位电路、调试/下载电路,对于STM32还需要启动选择电路。总之,刚开始如果不太懂电路的话,就抄别人的电路,然后自己拼凑。下图为stm32c8t6经典电路原理图 文章目录 STM32最小系统板电路知识学习 一、电源转换电路 二、JTAG/SWD调试接口电路 三、时钟电路 四、复位电路 提示:以下是本篇文章正文内容,下面案例可供参考 一、电源转换电路 开发板通常采用USB供电,通常USB都为5V,因此需要将5V转换成3.3V,使用TPS73633或者AMS1117芯片电源芯片即可实现。 首先设计电源入口部分,现在大多数开发板所使用的都是USB的5V供电,所以我们本次设计也采用USB接口供电,所以我们电源接口就采用5Pin的mini贴片的USB,将5V的电源引入开发板使用,其电路图如下,1脚为电源正极,5脚为负极,串接的二极管是为了保护我们的开发板,防止有个别的连接线极性不对烧坏板子,保护电路在我们设计任何电路时都要考虑到,这个大家以后自己设计时也要注意。这样我们就可以通过连接线将5V的USB电源引入到开发板中进行使用了。 接下来便是电源电路,STM32工作电压是DC3.3V,所以我们需要一个能将大于3.3V电压转换为稳定的3.3V电压的芯片,这里我们使用的是TPS73633或者AMS1117芯片电源芯片即可实现。 下图为TPS73633芯片的相关说明,TPS73633DBVR是一款3.3V固定输出低压降(LDO)线性稳压器,采用了一种新的拓扑-电压跟随器配置中的NMOS调整元件。使用具有低ESR的输出电容器,这种拓扑是稳定的,甚至可以在没有电容器的情况下运行。它还提供高反向阻塞(低反向电流)和接地引脚电流,该电流在所有输出电流值上都几乎恒定。该器件使用先进的BiCMOS工艺来产生高精度,同时提供非常低压降(LDO)的电压和低接地引脚电流。未启用时,电流消耗低于1uA,非常适合便携式应用。极低的输出噪声非常适合为VCO供电。该器件受热关断和折返电流限制保护。 二、JTAG/SWD调试接口电路 JTAG/SWD调试接口电路采用了标准的JTAG接法,这种接法兼容SWD接口,因为SWD只需要四根线(SWCLK、SWDIO、VCC和GND)。需要注意的是,该接口电路为JLINK或ST-Link提供3.3V的电源,因此,不能通过JLINK或ST-Link对STM32核心板进行供电,而是STM32核心板为JLINK或ST-Link供电。JLINK和ST-Link不仅可以下载程序,还可以对STM32微控制器进行在线调试。 三、时钟电路 MCU是一个集成芯片,由非常复杂的数字电路和其它电路组成,需要稳定的时钟脉冲信号才能保证正常工作。时钟如同人体内部的心脏一样,是芯片的“动力”来源。时钟产生一次,就推动处理器执行一下指令。除了CPU,芯片上所有的外设(GPIO、I2C、SPI等)都需要时钟,由此可见时钟的重要性。芯片运行的时钟频率越高,芯片处理的速度越快,但同时功耗也越高。为了功耗和性能兼顾,微处理器一般有多个时钟源,同时还将时钟分频为多个大小,适配不同需求的外设。下图为stm32的时钟树 这里我们将两个晶振电路,电源,以及各引脚的网络符号对应连接好即可,除去晶振和电源,其余的标号都是连接在我们引出的排针上边的,晶振电路这里包含了一个8MHz的主晶振,以及一个32.768kHz的内部RTC实时时钟晶振,这里时钟晶振作为预留,如果有用到时钟的小伙伴直接焊接上即可,方便使用,每个晶振后边并联的为起振电容,方便晶振起振,电源部分的电容C3-C7组成了一个低通滤波电路,目的是为了让32更好的工作 四、复位电路 嵌入式系统中,由于外界环境干扰,难免出现程序跑飞或死机,这时就需要复位让MCU重新运行。该电路将一个按键接在了NRST引脚,一旦按键按下,NRST就会接地,拉低NRST,实现复位。

    01-03 93浏览
  • 详解嵌入式屏幕能够显示汉字的原理

    LCD是嵌入式常见设备,如何在LCD上显示汉字和英文?矢量字体和点阵字体有何不同?同一个字符为何有多种编码? GB2312、GB18030指什么?他们之间有关系吗?嵌入式设备如何支持多国语言?从哪里获取字库?需要付费吗? 以上问题,本文一一道来! 一、如何在LCD上显示字符 在LCD上如何显示字符呢?这里我们讨论的是逻辑问题,不是LCD控制器驱动。 首先,我们要对LCD有一个概念:LCD也就是一个点一个点组成的一片点而已。本质上和1个LED一样。 1个单色LED,能显示亮灭。 将很对LED排成一行,就可以实现流水灯。 将多行LED组成一片,是啥?是LED点阵。 多块点阵,就能组成图文屏。 如果是三色LED组成的图文屏,就能显示视频动画。 无论是COG LCD,OLED LCD,还是TFT LCD,实际上都是一个点一个点组成的矩阵,和LED组成的图文屏一样。 当然,制造工艺不一样,控制显示内容技术也不一样,我们不讨论制造技术,控制技术后续会有单独课程讨论。 显然,要在图文屏上显示字符,只需将对应的LED点点亮。不同的字符应该点亮那些LED呢? 我们先逆向想,下图LED点阵上显示的汉字,如何将位置信息保存到代码中,以便下次使用? 这是一个16x16的LED点阵。 每个LED的状态仅仅是亮或灭,那么就可以使用1个bit表示其状态,1表示亮,0表示灭。 1个byte,8个bit,两个byte就可以表示一行LED的状态。 上图这个德字第一行就可以这样表示:0001 0000 0100 0000;也就是0x10,0x40; 按照此方法取得“德”字得完整显示信息: 0001 0000 0100 0000 0x10 0x40 0001 0000 0100 0000 0x10 0x40 0010 1111 1111 1110 0x2F 0xFE 0100 0000 0100 0000 0x40 0x40 1001 0111 1111 1100 0x97 0xFC 0001 0100 1010 0100 0x14 0xA4 0010 0100 1010 0100 0x24 0xA4 0110 0111 1111 1100 0x67 0xFC 1010 0000 0000 0000 0xA0 0x00 0010 1111 1111 1110 0x2F 0xFE 0010 0000 0100 0000 0x20 0x40 0010 0000 0010 0100 0x20 0x24 0010 0101 0010 0010 0x25 0x22 0010 0101 0000 1010 0x25 0x05 0010 1001 0000 1000 0x29 0x08 0010 0000 1111 1000 0x20 0xF8 用1个数组保存,如下: de_dot[32]={ 0x10, 0x40, 0x10, 0x40,0x2f, 0xfe, 0x40, 0x40, 0x97, 0xfc, 0x14, 0xa4,0x24, 0xa4,0x67, 0xfc, 0xa0, 0x00,0x2f, 0xfe,0x20, 0x40,0x20, 0x24, 0x25, 0x22, 0x25, 0x05, 0x29, 0x08, 0x20, 0xf8}; 得到了这个数组,在代码中按照取模的方式将其还原到LED点阵上,就能显示德字。 玩过8段数码管的朋友应该很熟悉,为了在数码管上显示数字,我们会在代码中定义0~9数字的显示掩码(数码管每个段亮灭的信息)。 点阵上显示汉字,无非就是LED更多了。 因此,要在LCD上显示字符,需要先知道如何描绘字符的信息,按照取模方式,将这写信息还原到LCD上,就能描绘出字符。 二、点阵字库 那什么是点阵字库呢? 上面说的“德”字的描绘信息数组就是点阵字库。 这个德字描绘出的效果,就是点阵字体。描绘信息保存的形式,就是点阵字库。 点阵字库有多种形式: 1、直接将信息用数组保存到代码中。(显示的内容较少时通常会这样做) 2、将点阵信息描绘到一张bmp图片,使用时根据信息文件(FNT)从图片中取信息。(很多游戏使用的就是这种贴图字体,也叫bmpfont) 3、将一堆点阵信息数组打包为1个bin文件,使用算法定位字符位置。(点阵较多,按照编码规范整合在一起) 4、按照电脑字体规范整合的字体。(比如windows的ttf字体,bdf字体) 三、取模方式 取模方式指的是单个bit位置信息组合成字节时使用的方式。 前面我们对德字取位置信息时:使用横向取模,并且高位在前。此外还有很多不同的取模方式,常见的方式如下: 尺寸 汉字通常有这些尺寸:12x12,16x16,24x24。在cog屏128*64像素的屏上,一般用12x12的汉字点阵,可以显示5行。 ASCII码的点阵通常和汉字高度一样,宽度是一半,比如,12x12的汉字配12x6的ASCII码,16x16的汉字配16x8的ASCII码。 四、矢量字体 矢量字体:矢量字体(Vector font)中每一个字形是通过数学曲线来描述的,它包含了字形边界上的关键点,连线的导数信息等,字体的渲染引擎通过读取这些数学矢量,然后进行一定的数学运算来进行渲染。 这类字体的优点是字体实际尺寸可以任意缩放而不变形、变色。矢量字体主要包括 Type1 、 TrueType、OpenType等几类。 Freetype:FreeType库是一个完全免费(开源)的、高质量的且可移植的字体引擎,它提供统一的接口来访问多种字体格式文件,包括TrueType, OpenType, Type1, CID, CFF, Windows FON/FNT, X11 PCF等。 矢量字体使用Freetype进行渲染后,最后得到的也是bitmap,毕竟,LCD就是一个一个点组成的。 理论上矢量字体可以无限放大而不失真。 但是,矢量字体渲染为较小字号的字,某些字(笔画多)可能失真非常严重。 五、字符编码 字符编码是指一种映射规则,根据这个映射规则可以将某个字符映射成其他形式的数据以便在计算机中存储和传输。 ASCII码 ASCII ((American Standard Code for Information Interchange): 美国信息交换标准代码)是基于拉丁字母的一套电脑编码系统,主要用于显示现代英语和其他西欧语言。 它是最通用的信息交换标准,并等同于国际标准ISO/IEC 646。ASCII第一次以规范标准的类型发表是在1967年,最后一次更新则是在1986年,到目前为止共定义了128个字符 。 Codepage 欧美很多国家的语言在ASCII码中没有定义,各个国家(或组织)就使用1个字节剩下的127个值映射他们需要的字符。 国家很多,定义很多,如何决定使用哪种映射呢? IBM、微软等系统就增加了CodePage的概念:每种映射分配一个编号。这些系统的代码页编号是不完全一样的。IBM定义的代码叫做OEM,微软定义的代码叫ANSI。 六、汉字编码 汉字字符数量很多,仅仅使用高位127个值不能表示。 因此国家标准组织定义了《信息交换用汉字编码字符集》。有三个版本: GB2312编码:1981年5月1日发布的简体中文汉字编码国家标准。GB2312对汉字采用双字节编码,收录7445个图形字符,其中包括6763个汉字。 GBK编码:1995年12月发布的汉字编码国家标准,是对GB2312编码的扩充,对汉字采用双字节编码。GBK字符集共收录21003个汉字,包含国家标准GB13000-1中的全部中日韩汉字,和BIG5编码中的所有汉字。 GB18030编码:2000年3月17日发布的汉字编码国家标准,是对GBK编码的扩充,覆盖中文、日文、朝鲜语和中国少数民族文字,其中收录27484个汉字。GB18030字符集采用单字节、双字节和四字节三种方式对字符编码。兼容GBK和GB2312字符集。 汉字编码使用分区概念,如下面分区,其中的双字节二区就是符合GB2312标准的双字节区。 双字节部分编码空间结构图 七、如何获得字库 在介绍如何获得字库之前,先说明版权问题 版权说的是字体,也就是一个字符描绘出来的效果。一个字体的形成方式通常是矢量字体或者点阵字库。 使用工具将一种字体的矢量字库转换为点阵字库,他们仍然是同一种字体,那么版权是一样的,依然属于矢量字体拥有者。 获取矢量字体 在你的电脑中就有很多字体。windows下的C:\Windows\Fonts下保存有你电脑安装的字体,这种字体通常是ttf标准。 这些字体一般都不是开源的,也就是不能免费商用到嵌入式设备上。开源的字体有:思源字体,可以免费商用。 八、获取点阵字体 我们更关心的是如何获取点阵字体。 前面说过,点阵字体有很多存在方式,那么我们就有很多获取方式。 从很早之前的电脑汉卡上获取点阵字库。这种方式比较难找,在github上可以找到一些外文的点阵字库,汉字的没找到。 从DOS系统获取汉字点阵这种点阵我用过的有HZ1616,HZ1212。这种字体的版权不是很清楚属于谁。不过这种字库因为很早,所以基本都是GB2312规范的,不包含生僻字。 从电脑字体中找点阵字体电脑字体常见的是TTF规范,这个规范比代表它只有矢量字体,很多电脑字体中会包含矢量字体和点阵字体。点阵字体一般是小号字。小号字用矢量字体渲染,效果不好,所有某种字体通常会带小号的点阵字体。 用取模工具取点阵信息 如果使用的字符很少,可以用《zimo3》等工具取模。 用点阵生成工具批量生成 比如易木雨软件工作室做的《字库制作软件.rar》,可以批量将矢量字库转为点阵字库。 从开源模块中找外文点阵 比如tslib触摸库中就含有英文点阵 买。有一个叫高通的公司,卖点阵字库芯片。有一个较北京中易的公司,有一套很好的点阵字库。这套点阵字库由于性能好,流传广,工作经验丰富的工程师一般都接触过;不过大部分公司可能都没有获得授权。还有其他很多做字体的公司也有点阵字库,比如方正。 自己用编辑工具一个一个画,然后用你的脑子将其转为数组。

    2024-12-19 141浏览
  • 如何减少嵌入式软件代码bug?看看这些问题

    在嵌入式开发软件中查找和消除潜在的错误是一项艰巨的任务。通常需要英勇的努力和昂贵的工具才能从观察到的崩溃,死机或其他计划外的运行时行为追溯到根本原因。在最坏的情况下,根本原因会破坏代码或数据,使系统看起来仍然可以正常工作或至少在一段时间内仍能正常工作。工程师常常放弃尝试发现不常见异常的原因,这些异常在实验室中不易再现,将其视为用户错误或“小故障”。然而,机器中的这些鬼魂仍然存在。这是难以重现错误的最常见根本原因指南。每当您阅读固件源代码时,请查找以下五个主要错误。并遵循建议的最佳做法,以防止它们再次发生在您身上。错误1:竞争条件竞争条件是指两个或多个执行线程(可以是RTOS任务或main() 和中断处理程序)的组合结果根据交织指令的精确顺序而变化的任何情况。每个都在处理器上执行。例如,假设您有两个执行线程,其中一个规则地递增一个全局变量(g_counter + = 1; ),而另一个偶然将其归零(g_counter = 0; )。如果不能始终以原子方式(即,在单个指令周期内)执行增量,则存在竞争条件。 如图1所示,将任务视为汽车接近同一十字路口。计数器变量的两次更新之间的冲突可能永远不会发生,或者很少会发生。但是,这样做的时候,计数器实际上不会在内存中清零。其值至少在下一个清零之前是损坏的。这种影响可能会对系统造成严重后果,尽管可能要等到实际碰撞后很长一段时间才会出现。最佳实践:可以通过必须以适当的抢先限制行为对原子地执行代码的关键部分,来避免竞争条件。为防止涉及ISR的争用情况,必须在另一个代码的关键部分持续时间内至少禁止一个中断信号。对于RTOS任务之间的争用,最佳实践是创建特定于该共享库的互斥体,每个互斥体在进入关键部分之前必须获取该互斥体。请注意,依靠特定CPU的功能来确保原子性不是一个好主意,因为这只能防止争用情况发生,直到更换编译器或CPU。共享数据和抢占的随机时间是造成竞争状况的元凶。但是错误可能并不总是会发生,这使得从观察到的症状到根本原因的种族状况跟踪变得异常困难。因此,保持警惕以保护所有共享对象非常重要。每个共享对象都是一个等待发生的事故。 最佳实践:命名所有潜在共享的对象(包括全局变量,堆对象或外围寄存器和指向该对象的指针),以使风险对于所有将来的代码阅读者而言都是显而易见的;在Netrino嵌入式C编码标准提倡使用“的G_ 为此,”前缀。查找所有可能共享的对象将是争用条件代码审核的第一步。 错误2:不可重入功能从技术上讲,不可重入功能的问题是争用状况问题的特例。而且,由于相关原因,由不可重入函数引起的运行时错误通常不会以可重现的方式发生-使它们同样难以调试。不幸的是,非重入功能也比其他类型的竞争条件更难在代码审查中发现。图2 显示了一个典型的场景。在这里,要抢占的软件实体也是RTOS任务。但是,它们不是通过直接调用共享对象而是通过函数调用间接操作。例如,假设任务A调用套接字层协议功能,该套接字功能调用TCP层协议功能,调用IP层协议功能,该功能调用以太网驱动程序。为了使系统可靠地运行,所有这些功能都必须是可重入的。但是,以太网驱动程序的所有功能都以以太网控制器芯片的寄存器形式操作相同的全局对象。如果在这些寄存器操作期间允许抢占,则任务B可以在将数据包A排队之后但在发送开始之前抢占任务A。然后,任务B调用套接字层功能,该套接字层功能调用TCP层功能,再调用IP层功能,该功能调用以太网驱动程序,该队列将数据包B排队并传输。当CPU的控制权返回到任务A时,它将请求传输。根据以太网控制器芯片的设计,这可能会重传数据包B或产生错误。数据包A丢失,并且不会发送到网络上。为了可以同时从多个RTOS任务中调用此以太网驱动程序的功能,必须使它们可重入。如果它们每个仅使用堆栈变量,则无事可做。因此,C函数最常见的样式固有地是可重入的。但是,除非精心设计,否则驱动程序和某些其他功能将是不可重入的。使函数可重入的关键是暂停对外围设备寄存器,包括静态局部变量,持久堆对象和共享内存区域在内的全局变量的所有访问的抢占。这可以通过禁用一个或多个中断或获取并释放互斥锁来完成。问题的细节决定了最佳解决方案。最佳实践:在每个库或驱动程序模块中创建和隐藏一个互斥量,这些互斥量不是本质上可重入的。使获取此互斥锁成为操作整个模块中使用的任何持久数据或共享寄存器的前提。例如,相同的互斥锁可用于防止涉及以太网控制器寄存器和全局或静态本地数据包计数器的竞争情况。在访问这些数据之前,模块中访问此数据的所有功能必须遵循协议以获取互斥量。注意非重入功能可能会作为第三方中间件,旧版代码或设备驱动程序的一部分进入您的代码库。 令人不安的是,不可重入函数甚至可能是编译器随附的标准C或C ++库的一部分。 如果您使用GNU编译器来构建基于RTOS的应用程序,请注意您应该使用可重入的“ newlib”标准C库,而不是默认库。 错误3:缺少volatile关键字如果未使用C的volatile 关键字标记某些类型的变量,则可能导致仅在将编译器的优化器设置为低级或禁用编译器才能正常工作的系统中出现许多意外行为。该挥发性预选赛期间变量声明,其中它的目的是为了防止优化的读取和变量的写入使用。例如,如果您编写清单1所示的代码,则优化器可能会通过消除第一行来尝试使程序更快速,更小,从而损害患者的健康。但是,如果将g_alarm 声明为volatile ,那么将不允许这种优化。最佳实践:将挥发 的关键字应该用于声明每个:由ISR和代码的任何其他部分访问的全局变量,由两个或多个RTOS任务访问的全局变量(即使已阻止了这些访问中的竞争条件),指向内存映射外设寄存器(或一组或一组寄存器)的指针,以及延迟循环计数器。 请注意,除了确保所有读写操作都针对给定变量之外,使用volatile 还通过添加其他“序列点”来限制编译器。除易失性变量的读取或写入之外的其他易失性访问必须在该访问之前执行。 错误4:堆栈溢出每个程序员都知道堆栈溢出是很不好的事情。但是,每次堆栈溢出的影响都各不相同。损坏的性质和不当行为的时机完全取决于破坏哪些数据或指令以及如何使用它们。重要的是,从堆栈溢出到它对系统的负面影响之间的时间长短取决于使用阻塞位之前的时间。不幸的是,堆栈溢出比台式计算机更容易遭受嵌入式系统的困扰。这有几个原因,其中包括:(1)嵌入式系统通常只能占用较少的RAM;(2)通常没有虚拟内存可回退(因为没有磁盘);(3)基于RTOS任务的固件设计利用了多个堆栈(每个任务一个),每个堆栈的大小都必须足够大,以确保不会出现唯一的最坏情况的堆栈深度;(4)中断处理程序可能会尝试使用这些相同的堆栈。使该问题进一步复杂化的是,没有大量的测试可以确保特定的堆栈足够大。您可以在各种加载条件下测试系统,但是只能测试很长时间。仅在“半个蓝月亮”中运行的测试可能不会见证仅在“一次蓝月亮”中发生的堆栈溢出。在算法限制(例如无递归)下,可以通过对代码的控制流进行自上而下的分析来证明不会发生堆栈溢出。但是,每次更改代码时,都需要重做自上而下的分析。最佳实践:启动时,在整个堆栈上绘制不太可能的内存模式。(我喜欢使用十六进制23 3D 3D 23,它看起来像ASCII内存转储中的篱笆' #==# '。)在运行时,让管理员任务定期检查是否没有任何涂料在预先设定的高水位上方标记已更改。 如果发现某个堆栈有问题,请在非易失性内存中记录特定的错误(例如哪个堆栈以及洪水的高度),并为产品的用户做一些安全的事情(例如,受控关闭或重置)可能会发生真正的溢出。这是添加到看门狗任务中的一项不错的附加安全功能。 错误5:堆碎片化嵌入式开发工程师并没有很好地利用动态内存分配。其中之一是堆碎片的问题。通过C的malloc() 标准库例程或C ++的new 关键字创建的所有数据结构都驻留在堆中。堆是RAM中具有预定最大大小的特定区域。最初,堆中的每个分配都会减少相同字节数的剩余“可用”空间。例如,特定系统中的堆可能从地址0x20200000开始跨越10 KB。一对4 KB数据结构的分配将留下2 KB的可用空间。可以通过调用free() 或使用delete 关键字将不再需要的数据结构的存储返回到堆中。从理论上讲,这使该存储空间可用于后续分配期间的重用。但是分配和删除的顺序通常至少是伪随机的,这导致堆变成一堆更小的碎片。若要查看碎片可能是一个问题,请考虑如果上述4 KB数据结构中的第一个空闲时会发生什么情况。现在,堆由一个4 KB的空闲块和另一个2 KB的空闲块组成。它们不相邻,无法合并。所以我们的堆已经被分割了。尽管总可用空间为6 KB,但超过4 KB的分配将失败。碎片类似于熵:两者都随时间增加。在长时间运行的系统(换句话说,曾经创建的大多数嵌入式系统)中,碎片最终可能会导致某些分配请求失败。然后呢?您的固件应如何处理堆分配请求失败的情况?最佳实践:避免完全使用堆是防止此错误的肯定方法。但是,如果动态内存分配在您的系统中是必需的或方便的,则可以使用另一种结构化堆的方法来防止碎片。 关键观察是问题是由大小可变的请求引起的。如果所有请求的大小都相同,则任何空闲块都将与其他任何块一样好,即使它恰巧不与任何其他空闲块相邻。图3 显示了如何将多个“堆”(每个用于特定大小的分配请求)的使用实现为“内存池”数据结构。许多实时操作系统都具有固定大小的内存池API。如果您可以访问其中之一,请使用它代替malloc() 和free() 。或编写自己的固定大小的内存池API。您只需要三个函数:一个用于创建新的池(大小为M 块N 字节);另一个分配一个块(来自指定的池);三分之一代替free() 。代码审查仍然是最佳实践,可以通过首先确保系统中不存在这些错误来避免许多调试麻烦。最好的方法是让公司内部或外部的人员进行全面的代码审查。强制使用我在这里描述的最佳实践的标准规则编码也应该会有所帮助。如果您怀疑现有代码中存在这些讨厌的错误之一,那么执行代码审查可能比尝试从观察到的故障追溯到根本原因要快。 原文:https://blog.csdn.net/weixin_44059661/article/details/107839764 文章来源于网络,版权归原作者所有,如有侵权,请联系删除。

    2024-12-19 210浏览
  • 如何设置TCP连接重置

    连接建立失败并不仅仅包含无响应问题,还有一种常见的情况,即RST(Reset)包的发送。RST包是TCP协议中用来进行“连接重置”的数据包,本文将围绕RST包进行详细展开讨论。 TCP连接中为何会有RST包?TCP RST包,即TCP头部中RST位设置为1的数据包。根据RFC 793,TCP RST包用于终止一个现有的连接或拒绝一个连接请求。当一个TCP端点希望立即终止一个连接时,它会发送一个RST包。因此,当客户端或服务器的任意一方需要拒绝连接,或认为连接出现错误时,即可以发送RST包重置当前TCP连接。RST包在TCP时序图中的体现如图1所示:图1:时序图中的TCP RST包 连接建立阶段的RST包如何分析?在连接建立阶段,服务器可发送RST包拒绝连接建立,如图2所示,在连接建立阶段,服务器发送RST包,表示服务器拒绝连接。图2:服务器拒绝连接如果服务器发送RST包拒绝连接,一般可能由如下原因导致: 服务器端口未开放:服务器在该端口未运行网络服务,或服务器上客户所请求的服务失效,则服务器会通过RST拒绝新连接。 服务器TCP连接数达到极限:如果服务器设置了tcp_abort_on_overflow=1,那么服务器在队列满时会发送RST包拒绝连接。 Time_Wait状态:如果客户端使用的当前socket在上一个连接刚刚结束,且服务器当前socket处于time_wait状态,则此时使用该socket的新连接请求会被服务器拒绝,返回RST包。 SYN包格式错误:客户端发送的SYN包携带了其它未经允许的标记(例如FIN、URG或其他标记),则服务器会拒绝连接并直接返回RST 防火墙策略不允许:如果客户端IP被禁止连接,则会话中会出现RST包,此种情况在后文中具体讨论。 如果在会话连接建立阶段出现了服务器RST的情况,建议从服务器位置分析流量,或在系统层面排查连接建立失败的原因。除服务器外,在连接建立阶段,客户端也有可能发送RST包,如图3、图4所示,在连接建立已经经历了SYN包、SYN/ACK包的交互后,甚至三次握手完成后,客户端突然发送RST包,表示客户端拒绝连接。图3:客户端在收到SYN/ACK包后发送RST图4:客户端在三次握手成功后立即发送RST对于图3、图4中出现的情况,可以直接判断为客户端发生异常或客户端正在发起端口扫描攻击。其中图3的流量为典型客户端发起TCP SYN端口扫描时序图,图4的流量为典型的客户端发起TCP Connect端口扫描时序图,如遇到此类时序图,请使用策略封禁该客户端IP地址,并检查客户端IP地址的其它流量,观察其有无其它攻击行为。 数据传输阶段的RST包如何分析?在数据传输阶段,客户端和服务器均可能随时发出RST,此时RST的原因一定是连接出现异常。对于此类故障,常见如下原因: 重传次数超限:TCP具有重传机制,当TCP尝试多次重传而无法收到对方的确认(或对方发来的确认无法被接收处理,例如序列号错误、校验和错误等种种字段错误)后,重传的一方将会认为连接出现错误,停止重传并发送RST包重置连接。如图5所示。 图5:多次重传后,服务器认为会话错误发送RST 连接长时间无数据交互:当客户端和服务器之间长时间(例如120秒)无数据交互时,其中一方可能认为会话超时,会向发送RST包重置连接。如图6所示。在客户端和服务器中间流量经过负载均衡或防火墙的场景,RST包也可能由负载或防火墙发出。 图6:120秒无交互后,服务器认为会话超时发送RST如果在会话交互阶段出现了RST的情况,建议通过分析时序图判断重置的原因是错误或超时。如果怀疑RST包是从中间设备发出,可以通过对比多个位置的同时段流量,确定RST包来源,从而进一步排查故障原因。 被防火墙阻断的会话时序图是什么样的?当ACL或安全策略匹配后的动作为Reject时,或安全设备是旁路部署无法直接丢弃流量时,安全设备会采用发送RST包方式去处理策略命中的会话。被RST的会话会因此中断。被安全策略阻断的会话如图7所示图7:连接被其他设备RST阻断通过图7可以看出,服务器响应了SYN/ACK包,而立刻回复了一个RST,这是由于发送RST包的设备为中间的安全设备,在进行旁路阻断时,只能通过发送RST进行阻断,而无法拦截服务器已发出的SYN/ACK包。因此,从客户端处能够看到服务器“同时回复”了SYN/ACK包和RST包。如果要问,为什么能判断这个RST一定是旁路阻断包,那可以仔细观察这些RST包,本文图1、图2、图4、图5、图6中的RST包,均为携带RST,ACK这两个标志位的“真RST包”,而图3、图7中的RST包是仅携带RST标志位的“假RST包”,另外,如果能够对比RST包和SYN/ACK包的IP TTL,则可以发现这两个包的IP TTL可能不同(也有部分设备能对TCP RST包的IP TTL拟真),说明其来自于两个不同的网络位置,如图8所示:图8:“假RST”与“真RST”的TTL不同图9则描述了一种更加容易理解的旁路阻断:客户端发送ClientHello包后,中间的旁路设备阻断了客户端,但未向服务器发送RST,导致服务器认为会话未中断,还在继续发送后续ServerHello数据包:图9:旁路阻断RST总之,如果会话过程中出现RST包,需要考虑该RST包是否由中间旁路阻断设备发出,可以通过该RST包的RST位、序列号、IP TTL等方式,或是直接多点抓包对比分析,综合判断。 连接断开阶段的RST包如何分析?在连接断开阶段,也可能出现RST包,这种情况一般是由于接收FIN包的一方或中间的负载、安全设备存在关于连接断开的优化机制。因为会话如果经过FIN包四次断开结束,先发FIN包的一方会经过TCP TIME_WAIT状态,经过2MSL时间才会进入到close状态,彻底关闭连接。在2MSL时间段内,此socket不可用。而被RST重置的会话,则不存在TIME_WAIT状态,因此,一些连接在出现FIN包后,负载/安全设备认为该连接已经可以被结束,于是发送RST包快速关闭会话。这种方法虽然快捷但不符合TCP协议标准。被RST加速断开的会话如图10和图11所示:图10:客户端FIN后出现RST快速断开连接图11:服务器FIN后出现RST快速断开连接在TCP连接的生命周期中,RST包扮演了一个关键角色,通过时序图观察RST包出现时机的分析,我们可以看到RST包在连接建立、数据传输、异常阻断、连接断开阶段的出现原因和影响。了解RST包的发送原因,是维护网络稳定性和安全性的重要技能。通过细致的RST包分析,可以有效地分析网络故障、优化网络性能和提升网络的安全性来源:科来文章来源于网络,版权归原作者所有,如有侵权,请联系删除。

    2024-12-18 189浏览
正在努力加载更多...
EE直播间
更多
广告