依旧是工作需要,参考嵌入式 C 语言(上) - 知乎 (zhihu.com)总结了一下嵌入式C语言所需的知识点笔记,方便后续学习。这也只是将嵌入式中所需要经常用到的C语言知识挑出来了,还是需要普通的C语言基础的。
一、与普通C语言的差别
嵌入式C语言 | 普通C语言 | |
运行环境 | 涉及硬件 | OS之上 |
标准库函数 | 无 | 有 |
分配内存 | 芯片RAM、FLASH等 | 电脑内存 |
处理器 | 芯片MCU(如ARM、DSP) | 电脑CPU |
开始函数 | 不一定是main() | main() |
二、基本结构及其特点
1、开始函数不一定是main();
2、语句以分号;结束;
3、注释规范:C语言实际上只有/*...*/的注释方式,尽量不使用//
(1)文件头部注释
/********************************************************************************
* @File name: biu.c
* @Author: fangqw
* @Version: 1.1
* @Date: 2021-3-19
* @Description: The function interface。
********************************************************************************/
(2)结构体、全局变量等的注释
int num; /*全局变量的作用*/
/*结构体的功能*/
typedef struct{
int h; /*High risk*/
int l; /*Low risk*/
int m; /*Middle risk*/
int i; /*Information risk*/
}risk;
(3)函数的注释
函数头部应进行注释,列出:函数的目的/功能、输入参数、输出参数、返回值、调用关系(函数、表)等。
/********************************************************
* Function name :insert_hhistory
* Description : Insert to bd_host_history
* Parameter :
* @ipsql SQL statement
* @host_level Risk level
* @total The total number of risk
* @t_id task id
* @t_uuid task uuid
* @ipaddr target ipaddr
* @end_time task end time
* Return :0 --success , other -- fail
**********************************************************/
int insert_hhistory(char* ipsql,risk host_level,int total,int t_id,char* t_uuid,char* ipaddr,long int end_time)
{
/*
* 如果程序过于复杂,这里可以写明,具体算法和思路。
*/
}
4、每个C程序至少由一个函数组成;
5、C语言的文件有两种格式:源文件.c文件和头文件.h文件,通常.c文件用于功能函数的实现,而.h文件用于预处理、宏定义和声明等;在嵌入式中,通常将某个硬件模块的功能实现函数及其声明和包含的全局变量声明分别处理到一个.c和.h文件中;
6、模块化设计;
7、层次化设计:我们可以笼统的将一个嵌入式工程系统分为驱动层和应用层。
三、数据类型
嵌入式系统中芯片的容量是有限的→需要了解变量所占用的存储空间(例如STM32xxx就表示此款芯片是32bit的芯片,STM8xxx表示此款芯片是8bit的芯片)
1、const用法
(1)修饰变量:功能是对变量声明为只读特性,并保护变量值以防被修改。const定义变量的同时还必须对其初始化,const可以放在数据类型的前面或者后面:
const int i=5;
//或
int const i=5;
若想对其重新赋值,例如i=10则是错误的用法,会编译错误。可用指针修改。
(2)修饰数组:与修饰变量类似,表明此数组具有只读性,不可修改,一旦被更改程序会出错。
const int array[5]={0,1,2,3,4};
// 或
int const array[5]={0,1,2,3,4};
(3)修饰指针
int i=5;
int k=10;
int const *p1 = &i;//限定指向空间的值不可修改:
int * const p2 = &k;//限定指针不可修改:
*p1=20错误,p1=&k正确;*p2=15正确,p2=&i错误;
(4)修饰函数参数
在C语言中const修饰函数参数对参数起限定作用,防止其在函数内部被意外修改,所限定的参数可以是普通变量也可以是指针变量,如:
void fun(const int x)
{
...
x = 10; // 对 x 的值进行了修改,错误
}
void fun1(const int *p)
{
2、作用域和static用法
(1)局部变量:声明在函数体内,有效范围在被定义的函数内,每次声明重新初始化,(如果在声明的时候有初始化赋值),不具有记忆能力。
void fun1(void){int i =0;for(i=0; i<10; i++){...}}
作用范围为函数fun1内,fun执行完毕i被释放。
void fun2(void){for(int i =0; i<10; i++){...}}
变量i的作用域则在for循环体内,当循环结束后,变量就会被释放,可见其作用域缩小了,这样的好处是增加了安全性和灵活性。
(2)全局变量:定义在函数体外,只被初始化一次
int k =0;void fun3(void){for(k=0; k<10; k++){...}}
作用范围为当前源文件和工程,其他源文件想要调用此变量需要在文件内使用关键字extern声明,如extern int k
(3)静态变量
(a)静态局部变量
void cnt(void){static int num =0; num++;}
静态局部变量满足局部变量的作用范围,但是其拥有记忆能力,不会在每次生命的时候都初始化一次。在第一次进入cnt函数的时候被声明,然后执行自加操作,num的值就等于1;当第二次进入cnt函数的时候,num不会被重新初始化变成0,而是保持1,再自增则变成了2,
(b)静态全局变量
static int k =0;
void set_k(void){ k =1;}
void reset_k(void){ k =0;}
int get_k(void){return k;}
静态全局变量则将全局变量的作用域缩减到了只当前源文件可见,其它文件不可见。假设我们不期望其它的文件有修改变量k的能力,但是其它的文件又需要变量k的值来进行逻辑运算,那我们就可以向上述例子那样做,在源文件中定义一个静态全局变量,同时使用函数对其的值进行修改和获取,对外只提供函数接口即可,其它文件通过函数接口间接的使用这个变量。
- 静态全局变量只在本文件可见,因而其它文件也可以定义相同名字的静态局部变量,但不建议这么做。
- static关键字除了用来修饰变量之外,还可以用来修饰函数,让函数仅在本文件可见,其它文件无法对其进行调用,例如在example1.c文件里面进行了如下定义:
static void gt_fun(void){...}
那么gt_fun这个函数就只能在example1.c中被调用,在example2.c中就无法调用这个函数。而如果不使 用static来修饰这个函数,那么只需要在example2.c中使用extern关键字写下语句extern void gt_fun(void);即可调用gt_fun这个函数。
3、extern用法
- extern关键字用于指明函数或变量定义在其它文件中,提示编译器遇到此函数或者变量的时候到其它模块去寻找其定义,这样被extern声明的函数或变量就可以被本模块或其它模块使用。
- extern关键字修饰的函数或者变量是一个声明而不是定义。
/* example.c */ uint16_t a =0; uint16_t max(uint16_t i, uint16_t j){return((i>j)?i:j);}
/* main.c */
#include <stdio.h>
extern uint16_t a;
extern uint16_t max(uint16_t i, uint16_t j);
void main(void) {
printf("a=%d\r\n", a);
printf("Max number between 5 and 9: %d\r\n", max(5, 9)); }
- extern关键字还有一个重要的作用,就是如果在C++程序中要引用C语言的文件,则需要用以下格式:
#ifdef __cplusplus
extern "C"{
#endif /* #ifdef __cplusplus */
......
#ifdef __cplusplus
}
#endif /* #ifdef __cplusplus */
这段代码的含义是,如果当前是C++环境(_cplusplus是C++编译器中定义的宏),要编译花括号{}里面的内容需要使用C语言的文件格式进行编译,而extern “C”就是向编译器指明这个功能的语句。
4、volatile用法
在嵌入式环境中用volatile关键字声明的变量,在每次对其值进行引用的时候都会从原始地址取值。针对其的任何赋值或者获取值操作都会被执行(而不会被优化)。由于这个特性,所以该关键字在嵌入式编译环境中经常用来消除编译器的优化。主要有以下场景:
- 修饰硬件寄存器
GPIOA->CRH |= 0x01;
该语句实现的是对GPIOA的CRH的寄存器的最低位拉高。CRH类型为 volatile unit16_t;如果仅使用uint16_t,就有可能被编译器优化,从而导致拉高CRH的最低位这个功能无法实现;
- 在有操作系统的工程中修饰会被多个任务修改的变量
在有操作系统(比如RTOS、UCOS-II、Linux等)的设计中,如果有多个任务在对同一个变量进行赋值或取值,那么这一类变量也应使用volatile来修饰保证其可见性。所谓可见即:当前任务修改了这一变量的值,同一时刻,其它任务此变量的值也发生了变化。
- 修饰中断服务函数中的非自动变量;(?)
5、struct用法
与普通C语言用法一致,使用分号表示结束;
定义:
struct [结构体名] {
类型标识符 成员名 1;
类型标识符 成员名 2; ...
类型标识符 成员名 n;
};
声明:
struct [结构体名] 结构体变量;
定义结构体只是告诉编译器如何表示数据,不分配内存空间;创建结构体变量则使用结构体模板为该变量分配空间。在内存中结构体成员是连续存储的。
6、enum用法
在C语言中可以使用枚举类型声明符号名称来表示整型常量,使用enum关键字可以创建一个新的“类型”并指定它可具有的值(实际上,enum常量是int类型,因此只要能使用int类型的地方就可以使用枚举类型)。枚举类型的目的是提高程序的可读性,其语法与结构的语法相同,如下:
enum [枚举类型名] {
枚举符 1,
枚举符 2 ...
枚举符 n,
};
- 枚举符都是有名称的常量,只要是能使用整型常量的地方就可以使用枚举常量,例如,在声明数组的时候可以使用枚举常量表示数组的大小,在switch语句中可以把枚举常量作为标签。
- 默认情况下,枚举列表中的常量都被赋予0,1,2等。
- 在枚举类型中,可以为枚举常量指定整数值:
如果只给一个枚举常量赋值,没有对后面的枚举常量赋值,那么后面的常量会被赋予后续的值,例如:enum levels{low=90, medium=80, high=100};
那么cat=0,lynx、puma、tiger的值分别是10、11、12。enum feline{cat, lynx=10, puma, tiger};
7、typedef用法
- 与#define不同:
i. 与#define不同,typedef创建的符号只受限于类型,不能用于值;
ii. typedef由编译器解释,不是预处理器;
iii. 在其受限范围内,typedef比#define更灵活;
-
Typedef unsigned char BYTE; BYTE x, y[10];
该定义的作用域取决于typedef定义所在的位置。如果定义在函数中,就具有局部作用域,受限于定义所在的函数。如果定义在函数外面,就具有文件作用域。
- 用typedef来命名一个结构体类型的时候,可以省略该结构的标签(struct):
typedef struct
{
char name[50];
unsigned int age;
float score;
}student_info;
student_info student={“Bob”, 15, 90.5};
- typedef常用于给复杂的类型命名,例如:
typedef void (*pFunction)(void);
把pFunction声明为一个函数,该函数返回一个指针,该指针指向一个void型。
-
使用typdef时要记住,typedef并没有创建任何新类型,它只是为某个已有的类型增加了一个方便使用的标签。
四、预处理器与预处理指令
- 预处理指令:
#define、#include、#ifdef、#else、#endif、#ifndef、#if、#elif、#line、#error、#pragma
- C预处理器在执行程序之前查看程序,因而被称之为预处理器。根据程序中的预处理指令,预处理器把符号缩写替换成其表示的内容(#define)。预处理器可以包含程序所需的其它文件(#include),可以选择让编译器查看哪些代码(条件编译)。预处理器并不知道C,基本上它的工作是把一些文本转换成另外一些文本。
- 在预处理之前会有一个编译过程,将文本处理好之后,让预处理器得到正确的逻辑行,程序就准备好进入预处理阶段,预处理器查找一行中以#号开始的预处理指令。然后我们就从#define指令开始讲解这些预处理指令。
五、文件包含#include
#include <stdio.h> // 在标准系统目录中查找 stdio.h 文件
#include “myfile.h” // 在当前目录中查找 myfile.h 文件
#include “/project/header.h” // 在 project 目录查找
#include “../myheader.h” // 在当前文件的上一级目录查找
对于我们自定义的文件,对于嵌入式开发来说,可能这些文件就有需要使用到的某些引脚宏定义、简单的功能函数宏定义等,以及某个源文件的全局变量和函数的声明等。
#include指令也不是只包含.h文件,它同样也可以包含.c文件。
六、条件编译
使用这些指令告诉编译器根据编译时的条件执行或忽略代码块。
1、#ifdef、#else和#endif指令
#ifdef HI /* 如果用#define 定义了符号 HI,则执行下面的语句 */
#include <stdio.h>
#define STR "Hello world"
#else /* 如果没有用#define 定义符号 HI,则执行下面的语句 */
#include "mychar.h"
#define STR "Hello China"
#endif
#ifdef、#else和C的if else很像,两者的主要区别在于预处理器不识别用于标记块的花括号{},因此它使用#else(如果需要的话)和#endif(必须存在)来标记指令块。
2、#ifndef指令
#ifndef指令与#ifdef指令的用法类似,但是逻辑相反。
3、#if和#elif
#if跟C语言中的if很相似,#if后面紧跟整型常量表达式,如果表达式为非零,则表达式为真。
七、指针用法
- 什么是指针?从根本上看,指针是一个值为内存地址的变量。正如char类型变量的值是字符,int类型变量的值是整数,指针变量的值是地址。
- 如何声明指针变量:声明指针变量时必须指定指针所指向变量的类型,不同的变量类型所占据的储存空间是不同的,一些指针操作需要知道操作对象的大小。另外程序必须知道储存在指定地址的数据类型。
类型说明符int表明了指针所指向对象的类型,解引用符号*表明声明的变量是一个指针。int *pi声明的意思是pi是一个指针,*pi是int类型。int *pi; // pi 是指向 int 类型变量的指针 char *str; // str 是指向 char 类型变量的指针 float *pf, *pg; // pf, pg 都是指向 float 类型变量的指针
- 使用指针的注意事项:
1、指针与数组
可以使用地址运算符&获取变量所在的地址,而在数组中同样可以使用取地址运算符获取数组成员中任意成员的地址
int week[7] = {1, 2, 3, 4, 5, 6, 7};
int *pw;
pw = &week[2];
printf("week is: %d", *pw);
2、指针与函数
- 指针在函数中的使用最简单的是作为函数的形参,如
假如这样使用int sum(int *pdata) { int i = 0; int temp = 0; for(i=0;i<10;i++) { temp = temp + (*pdata); pdata++; } return temp; }
int data[5] = {1, 2, 3, -1, -2}; int x = sum(data);
可以看到数组data的数组名即数组的首地址作为参数输入到函数sum里,而数组的大小只有5个int,函数sum计算的却是10个数的和,因而就会出现地址溢出,得不到正确的结果甚至于程序跑飞。为了避免这个问题,通常的解决方法是加一个数量形参:
int sum(int *pdata, int length) { int i = 0; int temp = 0; for(i=0;i<length;i++) { temp = temp + (*pdata); pdata++; } return temp; }x = sum(data, 5);
或者给出指针范围:
int sum(int *pStart, int *pEnd) { int i = 0; int temp = 0; int length = (pEnd - pStart)/2; // 假设一个 int 占 2 个字节 for(i=0;i<length;i++) { temp = temp + (*pdata); pdata++; } return temp; }x = sum(data, &data[4]);
- 作为函数指针:如何使用?
首先*表明pfun是一个指针变量,其次前面的int表示这个指针变量返回一个int类型的值,最后括号里面的int表明这个函数指针的形参是int类型的。int max(int a, int b) { return ((a>b)?a:b); } int main(void) { int (*pfun)(int, int); int a=-1, b=2, c=0; pfun = max; c=pfun(a, b); printf("max: %d", c); return 0; }
3、指针与硬件地址
比如在STM32F103ZET6中内部SRAM的基地址是0x20000000,我们想对这片空间的前256个字节写入数据,就可以使用指针指向这个基地址,然后开始写:
volatile unsigned char *pData = (volatile unsigned char *)(0x20000000);
int main(void) {
int i = 0;
for(i=0; i<256; i++) {
pData[i] = i+10; }
return 0; }
除了内存地址,还可以指向硬件外设的寄存器地址。
- 基本原则:
- 首先必须要指定指针的类型;
- 如果是普通指针变量,非函数形参或者函数指针,必须要给指针变量指定地址,避免成为一个“野指针”;
八、回调函数
- 回调函数:被作为参数传递的函数。
- 回调函数就是存在一个已知的函数体A,将这个函数体A的地址即函数名“A”(函数名即是这个函数体的函数指针,指向这个函数的地址)告知给另外某个函数B,当那个函数B执行到某一步的时候就会去执行函数A。
例如STM32的HAL库中GPIO的回调函数声明:
__weak void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
可以看到其函数名是:HAL_GPIO_EXTI_Callback,形参是GPIO_Pin表示引脚号(Px0~Px15, x=A,B,C,D,E,F,G),从这个函数的名称出发,可以大致明确这是一个引脚的外部中断(EXTI)的回调函数。然后大家看到前面还有个“__weak”,这是虚函数的修饰符,告诉编译器如果用户在其它地方用void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)重新定义了此回调函数那么优先调用用户定义的,否则调用这个虚函数修饰的回调函数。
在GPIO的外部中断服务函数中被调用:
void HAL_GPIO_EXTI_IRQHandler(uint16_t GPIO_Pin)
{
/* EXTI line interrupt detected */
if (__HAL_GPIO_EXTI_GET_IT(GPIO_Pin) != 0x00u) {
__HAL_GPIO_EXTI_CLEAR_IT(GPIO_Pin);
HAL_GPIO_EXTI_Callback(GPIO_Pin); } }
九、位运算
位运算是指二进制位之间的运算。在嵌入式系统设计中,常常要处理二进制的问题,例如将某个寄存器中的某一个位置1或者值0,将数据左移5位等。
- 按位与运算符(&):都1则1,否则0;
- 按位或运算符(|):都0则0,否则1;
- 按位取反运算符(~):~1011=0100;
- 左移(<<)和右移(>>):设unsigned char val=10111001
val=val<<3表示val左移3位,然后赋值给val,左移过程中,高位移出去后被丢弃,低位补0,最后val结果为1100100;
val=val>>3表示val右移3位,然后赋值给val,右移过程中,低位移出去后被丢弃,高位补0,最后val结果为00010111。
5.清0或置1:使用位运算符。
例如MCU的ODR寄存器控制引脚的输出电平高低,寄存器为32位,每位控制一个引脚的电平。假设需要控制GPIOB的1号引脚输出电平的高低,设置该寄存器第0位为1,输出高电平,设置该寄存器第0位为0,输出低电平。
#define GPIOB_ODR (*(volatile unsigned int *)(0x40010C0C))
GPIOB_ODR &= ~(1<<0);
GPIOB_ODR |= (1<<0);
第一行:使用#define定义了GPIOB_ODR 对应的内存地址为0x40010C0C。该地址为MCU的ODR寄存器地址。
第三行:GPIOB_ODR &= ~(1<<0)实际是GPIOB_ODR = GPIOB_ODR & ~(1<<0),先将GPIOB_ODR和~(1<<0)的进行与运算,运算结果赋值给GPIOB_ODR。1<<0的值为00000000 00000000 00000000 00000001,再取反为11111111 11111111 11111111 11111110,则GPIO_ODR的第0位和0与运算,结果必为0,其它位和1运算,由GPIO_ODR原来的值决定结果。这就实现了,只将GPIO_ODR的第0位清0,其它位保持不变的效果,实现了单独控制对应引脚电平输出低。
第四行:GPIOB_ODR |= (1<<0)实际是GPIOB_ODR = GPIOB_ODR | (1<<0),先将GPIOB_ODR和(1<<0)的进行或运算,运算结果赋值给GPIOB_ODR。1<<0的值为00000000 00000000 00000000 00000001,则GPIO_ODR的第0位和0或运算,结果必为1,其它位和0运算,由GPIO_ODR原来的值决定结果。这就实现了,只将GPIO_ODR的第0位置1,其它位保持不变的效果,实现了单独控制对应引脚电平输出高。