历史上的今天
今天是:2024年09月08日(星期日)
2020年09月08日 | AVR单片机教程——走向高层
2020-09-08 来源:eefocus
在系列教程的最后一篇中,我将向你推荐3个可以深造的方向:C++、事件驱动、RTOS。掌握这些技术可以帮助你更快、更好地开发更大的项目。
本文涉及到许多概念性的内容,如果你有不同意见,欢迎讨论。
关于高层
这一篇教程叫作“走向高层”。什么是高层?
我认为,如果寥寥几行代码就能实现一个复杂功能,或者一行代码可以对应到几百句汇编,那么你就站在高层。高层与底层是相对的概念,没有绝对的界限。
站得高,看得远,这同样适用于编程,我们要走向高层。高层是对底层的封装,是对现实的抽象,高层相比于底层更加贴近应用。站在高层,你可以看到很多底层看不到的东西,主要有编程工具和思路。合理利用工具,可以简化代码,降低工作量;用合适的思路编程,更可以事半功倍。
但是,掌握高层并不意味着忽视甚至鄙视底层,高层建立在底层基础之上。其一,有些高层出现的诡异现象可以追溯到底层,这样的debug任务只有通晓底层与高层的开发者才能胜任;其二,为了让高层实现复杂功能的同时获得可接受的运行效率,底层必须设计地更加精致,这就对底层提出了更高的要求。
相信你经过一期和二期的教程,已经相当熟悉AVR编程的底层了。跟我一起走上高层吧!
C++
C++继承自C,兼有低级语言和高级语言的特性。C++代码可以被编译为汇编语句,这决定了它的高效;C++支持过程式、面向对象、泛型等编程范式,还有庞大的标准库,这决定了它的高级。所有C++代码都可以转换成C代码,使用C++绝非必要,但是不能仅凭这一点而否定C++——因为同理,C也是没有必要的,你为什么还要学C呢?我们使用C++,就是要发挥它的高级。
在几十年的发展过程中,C++委员会制定了多个标准,主要的有C++98和C++11,C++11添加了很多新特性,既能简化程序又能提升性能。本文写于下一个主要标准C++20即将发布之际,主流编译器对C++11的支持已经很完整,所以我的建议是,如果想学C++,按照C++11标准来学。
在AVR平台上写C++比较特殊,在于工具链没有提供C++标准库。如果你想用标准库,无论是IO设施还是容器算法,要么委曲求全,用网上能找到的不完整的实现,要么自己写。这是AVR很劝退C++的一点,对此我的建议是,不要把AVR作为你学习C++的平台,尽管这一节讲的是C++给AVR单片机开发带来的益处。用AVR来操练C++倒是有很多意想不到的好处。
说起AVR与C++,还不得不提起Arduino,这个平台从AVR起家,一直使用C++语言,是AVR平台上C++的最大甚至唯一的应用。我想,如果你能读到这里,你对Arduino肯定不会陌生,方便易用是它的核心卖点之一。事实上,C++这门语言为它提供了不少帮助,而C++的威力还不止于此。我将以范式为线索,介绍C++的高级之处。
在面向对象的世界中,对象和消息是主角,语句是创建对象和规定消息传递方式的工具。定义对象需要用类,定义消息需要类中的函数,安排类之间的关系需要继承和虚函数,这些是功能上的配角,编程逻辑上的主角。
代码中的对象是现实中的对象的抽象,由于单片机往往是跟现实世界打交道,这一点比较容易理解。比如,开发板上的每一个外设,包括LED、按键等,都是对象。
#include #define F_CPU 25000000 #include class Led { public: Led(uint8_t index) { mask = 1 << (4 + index); DDRC |= mask; } void on() { PORTC |= mask; } void off() { PORTC &= ~mask; } private: uint8_t mask; }; Led red(0), yellow(1), green(2), blue(3); int main() { while (1) { red.on(); blue.off(); _delay_ms(500); red.off(); blue.on(); _delay_ms(500); } } 在这个程序中,我定义了类Led,它有3个函数:构造函数Led(uint8_t),设置mask并在硬件上初始化LED;on和off,分别开和关LED。然后创建了4个全局变量,分别代表4个LED。最后在main中使用,red.on()使红灯亮,是不是很形象呢?除了形象,你也许还注意到,我没有显式调用含有DDRC的那个函数,实际上它在main之前创建全局变量的时候被调用,自动地初始化硬件。我打赌你之前一定忘记过初始化,而C++帮你解决了这个问题。对象的构造、复制、移动、销毁,以及内存分配,都可以交给C++的语法和标准库来处理。 上面这个程序实际上是基于对象范式的,它体现了抽象和封装。面向对象则更进一步,体现了继承和多态。继承是类与类之间的关系,如果类D继承自类B,那么D类型的对象就包含B类型对象所包含的一切内容,类B称为类D的基类,类D称为类B的派生类。多态是指相同的语句表现出不同的行为,换言之不同行为可以有统一的接口,可分为编译期多态和运行时多态。运行时多态体现为虚函数:基类定义虚函数,派生类们实现虚函数,通过基类调用时,实际属于不同派生类的对象表现出不同的行为。很难理解吧,我们通过实例来看: #include class Shape { public: virtual void draw() const = 0; }; class Line : public Shape { public: Line(uint8_t x1, uint8_t y1, uint8_t x2, uint8_t y2) : x1(x1), y1(y1), x2(x2), y2(y2) { } virtual void draw() const override { // Bresenham line... } private: uint8_t x1, y1, x2, y2; }; class Circle : public Shape { public: Circle(uint8_t x, uint8_t y, uint8_t r) : x(x), y(y), r(r) { } virtual void draw() const override { // Bresenham circle... } private: uint8_t x, y, r; }; int main(void) { Line line(0, 0, 127, 63); Circle circle(64, 32, 16); Shape* shapes[2]; shapes[0] = &line; shapes[1] = &circle; for (uint8_t i = 0; i != sizeof(shapes) / sizeof(*shapes); ++i) shapes[i]->draw(); while (1) ; } override是C++11引入的关键字,你需要在项目属性->Toolchain->AVR/GNU C++ Compiler->Miscellaneous->Other flags中写-std=c++11,以启用C++11标准。 这个程序涉及3个类: 基类Shape定义了虚函数draw,派生类Line和Circle提供了不同的实现。main创建了Line和Circle类各一个对象,把指针放到Shape*类型的数组shapes中,然后通过指针调用draw函数,结果是Line::draw和Circle::draw分别被调用一次。要注意,只有通过指针或引用调用虚函数才能多态。引用用类型后的&表示,功能与指针类似,但不用写取地址和解引用符号,形式上更加简洁一点。 正如你所见,面向对象范式可以帮助你构建起现实中的对象之间的关系。在大型程序中,类与类、对象与对象、类与对象之间有复杂的联系,形成了各种设计模式。 C++和C一样可以自定义数据结构,但是不像C一样用宏来定义ADT,也不用void*来抹去类型,而是引入了模板,可以把元素类型作为模板参数定义类和函数,一个模板类或模板函数可以实例化成很多个类或函数。比如我们在UART一讲中写过的队列,在C++中可以这么写: #include template class Queue { public: Queue() = default; Queue(const Queue&) = delete; Queue& operator=(const Queue&) = delete; Queue(Queue&&) = delete; Queue& operator=(Queue&&) = delete; void push(const T& element) { data[tail] = element; tail = increase(tail); } void pop() { head = increase(head); } bool empty() const { return head == tail; } bool full() const { return increase(tail) == head; } const T& peek() const { return data[head]; } private: T data[S]; size_t head = 0; size_t tail = 0; static size_t increase(size_t value) { if (++value == S) value = 0; return value; } }; 顺便把UART那些函数改写一下: #include #include #include #include #include #define F_CPU 25000000 #include class Uart { public: Uart() { UCSR0B = 0 << UDRIE0 // UDRE interrupt disabled | 1 << TXEN0; // TX only UCSR0C = 0b00 << UMSEL00 // asynchronous USART | 0b10 << UPM00 // even parity | 0 << USBS0 // 1 stop bit | 0b11 << UCSZ00; // 8-bit UBRR0L = 40; // 38400bps } void print(char c) { bool full = true; while (1) { ATOMIC_BLOCK(ATOMIC_FORCEON) { if (!queue.full()) full = false; } if (!full) break; // if full, wait until buffer is not full } ATOMIC_BLOCK(ATOMIC_FORCEON) { if (queue.empty()) UCSR0B |= 1 << UDRIE0; queue.push(c); } } void print(int i) { char str[10]; itoa(i, str, 10); print(str); } void print(const char* s) { for (; *s; ++s) print(*s); } void println() { print('n'); } template void println(const T& t) { print(t); println(); } void _interrupt() { UDR0 = queue.peek(); queue.pop(); if (queue.empty()) UCSR0B &= ~(1 << UDRIE0); } private: Queue } uart; ISR(USART0_UDRE_vect) { uart._interrupt(); } int main() { for (char c = ' '; c <= '~'; ++c) uart.print(c); uart.println(); for (int16_t i = 0; ; ++i) { uart.println(i); _delay_ms(500); } } 有什么好处呢?第一段代码把原来为char编写的64字节队列缓冲区改写成任意类型、任意长度的队列类模板Queue,以类型T与大小S为模板参数。Uart类中创建了Queue 第二段代码是把C代码转换成了基于对象风格的C++代码,这个刚刚介绍过了,亮点在于函数print和println。print这一个函数名字对应参数为char、int和const char*的三个版本,这三个函数是重载函数。如果你给print函数传一个参数,编译器会帮你匹配到最合适的一个重载。在没有模板的情况下,你可以把重载看作是一个人性化的功能,毕竟你是知道实际上哪个函数会被调用的,如果你给函数名字加个表示参数类型的后缀,你也可以不使用重载,只不过这件事现在可以交给编译器了。 讲到函数重载,不得不顺便提一下运算符重载。私以为,运算符重载是C++最美丽的特性之一。在Java中你可以把两个字符串用+号连接,C++也可以;C++还允许你赋予运算符以意义,即为原本语义上不成立的运算符编写代码,它们往往与形象或数学上的意义相契合,比如用operator<<输出,再比如写有理数Rational类然后重载四则运算和比较运算符,而Java中只能覆写Object类的equals方法。况且直观上,operator==是比equals更加原生的写法——==是运算符,属于语言核心,而equals是函数名字,属于标准库。总之,函数重载省去了你为相同功能的函数起不同名字的麻烦,运算符重载则更加直接,连函数名字都省了。 回到正题,在有模板的情况下,重载不再仅仅是人性化功能,而是与泛型紧密结合了。println是一个函数模板,尽管你知道它只能接受char、int和const char*类型的参数,但是写成模板一可以省事,二允许添加额外的print重载而不改变println。如果给print加上后缀,你能在println中知道要调用哪个吗?这也是一种多态,编译期多态。 不过,如果你给println传了一个错误的类型,编译器给的错误信息会很难看,这是C++中模板错误的通病。如果不跳票的话,C++20引入的concept会解决这个问题。 Uart还可以再优化一下,让它继承自基类Print,其中定义了virtual print(char),其他函数照抄,Uart中只需覆写这一个print(char)函数,其他print和println都可以删去,而客户依然可以使用它们。这是因为,虽然println定义在Print中,但println中调用的print(char)是虚函数,还是会回到Uart::print(char)上来。同样地还可以有Oled类继承自Print类,同样覆写自己的print(char)。如果一个函数debug需要打印,它应该接受Print&类型的参数,传入Uart和Oled的实例可以分别实现在串口和OLED屏上打印。 
上一篇:ATmega328P定时器详解
下一篇:AVR单片机教程——示波器
史海拾趣
|
感谢您点击本人第一贴 谢谢~ 谢谢~ 兴趣爱好 相接触下arm (ToT)可是淘宝的开发板真的让人眼花缭乱………… *********请问应该怎样选购********* 可否推荐下4000以 ...… 查看全部问答> |
|
问题: 硬件:EPDZ3338+触摸屏(层电阻阻值在5-6K) 芯片集成了触摸屏驱动,在检测的时候,只要设定触摸屏检测标志位就可打开触摸屏检测有无按下,按下的时候会产生中断,通过这个中断A/D转化实现其他的功能,现在就是中断出不来,不管怎么按下啊 ...… 查看全部问答> |
|
当前CPLD的时钟输入用的是3.3V供电的TCXO,不知道凭高手的经验来看,会不会出问题?TCXO说输出峰峰值0.8V,还在一定的负载的前提下,真是心里没底。顺便告诉小弟一下在选择CPLD和FPGA是如何去处理输入时钟的问题?对输入时钟的幅度有没有比较高的要 ...… 查看全部问答> |
|
觉得还不错,与大家分享一下! 1.定义的变量不要太多。低128位为用户定义变量的存放区域(默认时),也可以把变量放在高128位,但容易出错,尽量少放,最好不放。通过*.M51可以查看内存变量的存放,最好不要超过110个字节,否则 ...… 查看全部问答> |
|
参加全国比赛,方向是电源类。对模块的供电电源设计存在些问题,想请教下各位高手 本帖最后由 paulhyde 于 2014-9-15 09:11 编辑 由于本人参加此次全国大学生电子电路设计大赛,方向是电源类。由于电源类的 题目中,不允许使用现有电源,只给220V的交流电。其他的电压都需要自己做。 例如微处理器及显示模块的供电,驱动电路等 ...… 查看全部问答> |




