C语言是一门面向过程的计算机编程语言,与C++、C#、Java等面向对象编程语言有所不同。C语言的设计目标是提供一种能以简易的方式编译、处理低级存储器、仅产生少量的机器码以及不需要任何运行环境支持便能运行的编程语言。
C语言诞生于美国的贝尔实验室,由丹尼斯·里奇(Dennis MacAlistair Ritchie)以肯尼斯·蓝·汤普森(Kenneth Lane Thompson)设计的B语言为基础发展而来。在它的主体设计完成后,汤普森和里奇用它完全重写了UNIX,且随着UNIX的发展,c语言也得到了不断的完善。1989年,诞生了第一个完备的C标准,简称“C89”,也就是早期的“ANSI C”,截至2023年,最新的 ISO的C语言标准为"C2x"。 👴
作为嵌入式工程师,跟C语言打交道是不可避免的。本篇文章抛弃了根据C语言语法点逐个分析讲解的做法,采用依托Liunx开发环境,在介绍完基本概念之后,直接从实际的问题入手讲解,也就是说是“倒过来讲”。因为我们依托的是Linux环境,我们使用的C库其实是 GNU C。
Linux开发环境搭建
现在可以使用WSL + VS Code Remote来搭建windows与Linux的交叉开发环境,各位如果喜欢这种新的方式可以移步这里。我下面要介绍的是用PC机win10+虚拟机64位Ubuntu 14.04+Source insight, smba来搭建。
首先安装VMware,下载Ubuntu镜像文件,ubuntu-14.04.5-desktop-amd64.iso。新建虚拟机,根据需要选择磁盘容量之后,点击完成。
修改虚拟机设置,根据后续需要设置虚拟机内存大小,在 CD/DVD(SATA)项,选择光盘提供的 ubuntu-14.04.5-desktop-amd64.iso 镜像文件。网络选择桥接模式。点击运行之后,按步骤安装Ubuntu即可。
按住键盘Ctrl + Alt + t ,打开一个命令解析器,输入sudo passwd root,添加一个root密码,这儿密码配置成1。设置开机时可以选择root 登录,执行命令gedit /usr/share/lightdm/lightdm.conf.d/50-unity-greeter.conf,打开编辑50-unity-greeter.conf 文件,在打开文件中添加如下信息,来设置登录时可以选择用户登录,如下图所示:
然后再在shell终端输入命令gedit /root/.profile,添加tty -s &&信息,如下
点击保存,重启后既可以选择root登陆了。
为了方便在windows主机编辑代码,可以安装samba服务器,当然,如果您使用Vim比较熟悉的话,作为嵌入式工程师直接用Vim修改系统代码即可。输入命令apt-get install samba smbclient,遇提示[Y/n]敲回车默认安装。修改配置文件,编辑smb.conf 文件:vi /etc/samba/smb.conf。在配置文件的最末尾加上下面内容:
设置Samba用户和密码,命令smbpasswd -a root 接着按提示输入密码,重新启动samba服务,命令/etc/init.d/samba restart,查看Ubuntu ip地址,命令ifconfig:
最后在电脑-计算机-下右键添加一个网络位置,输入:\\上面查看的ip地址\share,点击下一步
修改网络名称,点击下一步,按提示输入Samba用户名密码,点击确定。
成功完成Samba共享访问后,会看到Ubuntu系统下Samba共享的文件夹。
PS:上面共享目录是整个Ubuntu系统的目录,我们一般只需要访问/home下目录,所以需要修改目录对应的权限如修改qihua目录权限,命令chmod 777 /home/qihua/ 。
在这里说一下Linux下的编译器:gcc。gcc的编译过程:C源文件-预处理-编译-汇编-链接-可执行文件。
预处理:gcc -E hello.c > hello.i (重定向保存,预处理文件后缀为.i)
编译:gcc -S hello.i (默认产生后缀为.s的目标文件)
汇编:gcc -c hello.s (默认产生后缀为.o的目标文件)
链接:gcc hello.o -o hello (指定生成可执行文件的名字)
可执行文件:./hello 执行当前目录下的可执行文件hello
当然,我们在实际编译的过程中不需要这样一步一步执行,使用 gcc hello.c -o myhello (指定生成可执行文件的名字)即可将源文件编译成可执行文件,也可以使用make hello ,使用默认makefile生成可执行文件hello。
C基本概念
在 C 语言中,数据类型指的是用于声明不同类型的变量或函数的一个广泛的系统。变量的类型决定了变量存储占用的空间,以及如何解释存储的位模式。C中的数据类型可以分为以下几种:
基本数据类型(short;int; long;float;double;char)
构造类型(array;struct;union;enum)
指针类型
空类型(void)
float类型数无法和一确切的数比较是否相等(float类型本身并不精确,),不同形式的0值,数值0,字符'0',字符串"0",转义符0'\0',以及指针空NULL。
常量是在程序执行过程中值不会发生变化的量,包括整型常量,实型常量,字符常量,字符串常量,标识常量。
整型常量:1790,34,56。
实型常量:3.14,2.56,0.67。
字符常量:单引号引起来的单个字符或转义字符,如'a','D','\n','\0','\ddd'(三位八进制数),'\xhh'(两位16进制数) '\015','\x7f','\018'(非法!)。
字符串常量:双引号引起来的一个或多个字符组成的序列,如"helloworld","a",""(空字符串,只有一个'\0'占用一个字节)"abcd\n\021\018"(特殊)。
标识常量:#define,处理在程序预处理阶段,占编译时间,优点是一改全改,缺点是不检查语法,只是单纯的宏体与宏名之间的替换。
变量是用来保存一些特定内容,并且在程序执行过程中值随时会发生变化的量。定义如下图:
标识符:由字母,数字,下划线组成且不能以数字开头的一个标识序列,取标识符尽量做到见名生义。笔者一般采用驼峰命名法,不同公司会有不同的要求。
数据类型:基本数据类型 + 构造类型
值:注意与数据类型匹配,否则会出现精度丢失等问题。
变量可以添加关键字来说明存储类型。
auto:默认,自动分配空间,自动回收空间
static:静态型,自动初始化为0值或空值,并且其变量的值具有继承性,常用于修饰变量或函数。
register:寄存器类型(建议性关键字),register int i = 1; 由编译器决定是否存储在寄存器中, 大小有限制只能用来定义局部变量,32位机器只能定义32位大小的数据类型,如double就不可以,寄存器没有地址,所以无法打印寄存器类型变量的地址进行查看或使用。
extern:说明型,不能改变被说明的变量的值或类型。
变量具有生命周期和作用范围,全局变量是作用范围从定义位置开始直到程序结束,变量一直存在于内存中。局部变量的作用范围从申明位置开始直到当前块作用域结束 ,当前块结束后内存资源释放。
C语言包含很多中运算符,参与运算的操作数个数为1的单目运算符,条件运算符,以及赋值运算符。在同一条语句中运算符之间根据优先级排序执行运算顺序。
注意%要求左右操作数必须为整型, == 和 = 的不同区别(别看这个简单,我review别人代码发现过多次这两个用混了),逻辑运算符(&&, ||的短路特性。
作为嵌入式工程师 ,位运算是我们常常需要用到的。
将操作数中第n位置1,其他位不变:num = num | 1 << n;
将操作数中第n位清0,其他位不变:num = num & ~(1 << n);
测试第n位:if(num & 1 << n)
从一个指定宽度为w的数中取出第(m -> n)位:(num << (w-n)) >> (w-n+m)
输入输出与流程控制
从Shell终端输入a, b, c的值,求二次方程的根( , x = (-b +/- sqrt(delta)) / 2a )。代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#define WEIGHT 3.0e-23
#define KQ 950
static void root()
{
float a, b, c;
float delta;
float x1, x2;
printf("please enter for a, b, c: \n");
scanf("%f%f%f", &a, &b, &c);
delta = b*b - 4*a*c;
if(delta < 0)
{
fprintf(stderr, "Input error.\n");
exit(1);
}
x1 = (-b + sqrt(delta)) / (2*a);
x2 = (-b - sqrt(delta)) / (2*a);
printf("x1 = %f\n", x1);
printf("x2 = %f\n", x2);
return;
}
int main()
{
root();
exit(0);
}
以上代码演示的是格式化输入输出函数,涉及输入/输出的库函数有以下三类:
格式化输入输出函数:int scanf(const char *format, ...);int printf(const char *format, ...)。注意%s作为输入项非常危险,其不会提示越界问题。
字符输入输出函数: int getchar(void); int putchar(int c);
字符串输入输出函数:char *gets(char *s);int puts(const char *s)。gets函数十分危险,可以用fgets,getline替代
百钱买百鸡:鸡翁一,值钱五;鸡母一,值钱三;三鸡雏,值钱一。百钱买百鸡,问鸡翁,鸡母,鸡雏各几只。代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
static void Buychicken()
{
int a, b, c;
for(int a = 0; a < 20; a++)
{
for(int b = 0; b <= 33; b++)
{
c = 100 - a - b;
if(c % 3 == 0)
{
if(a*5 + b*3 + c/3*1 == 100)
printf("🐓 = %d, 母鸡 = %d, 小鸡 = %d\n", a, b, c);
}
}
}
return;
}
int main()
{
Buychicken();
exit(0);
}
以上这个例子for循环和if判断。C语言的流程控制分以下三类:
顺序:语句逐句执行,C语言为面向过程语言,语句顺序执行。
选择:出现一种以上情况,if....else;switch....case。
循环:语句块循环执行,while;do....while;for;if....goto。
还有一些辅助控制的关键字,像是continue不执行下面的语句,回到循环开始;break跳出循环。
指针与函数
"follow me", "basic", "great", "hello", "hi"五个字符串根据strcmp()比较结果进行排序。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main()
{
char* name[5] = {"follow me", "basic", "great", "hello", "hi"};
char* tmp = NULL;
int flag = 0, num;
for(int i = 0; i < 5; i++)
{
printf("%s\n", name[i]);
}
for(int i = 0; i < 4; i++)
{
for(int j = i+1; j < 5; j++)
{
if(strcmp(name[j], name[i]) < 0)
{
flag = 1;
num = j;
}
}
if(flag)
{
flag = 0;
tmp = name[i];
name[i] = name[num];
name[num] = tmp;
}
}
printf("\nafter sort:\n\n");
for(int i = 0; i < 5; i++)
{
printf("%s\n", name[i]);
}
exit(0);
}
上面这个例子涉及到字符串的存储,要使用一个“hello”字符串,有如下两种方式:
char *str = "hello"; str = "world"; puts(str);
char str[] = "hello"; strcpy(str, "world"); puts(str);
指针作为C语言中最为重要的概念,它允许我们直接访问某个地址下的数据,下面是几个涉及指针的关键知识点。
指针分不同数据类型的指针,int * ptr与char *ptr在内存中前者指向一个int类型数据的存储地址(一般为32位),而后者指向的是一个char类型数据的存储空间,ptr++在数值上前者增加了4,而后者增加了1。
指针定义时可以使用空类型void* p = NULL,后边使用*(int *)p,强制类型转换之后再获得指针指向数据。
避免野指针问题,内存空间释放之后记得把指向此空间的指针置为空。
指针还允许我们跳到不同的代码块,这个时候指针的名称叫做函数指针。下面这个例子是一个函数指针的例子:
# include <stdio.h>
int Max(int, int); //函数声明
int main(void)
{
int(*p)(int, int); //定义一个函数指针
int a, b, c;
p = Max; //把函数Max赋给指针变量p, 使p指向Max函数
printf("please enter a and b:");
scanf("%d%d", &a, &b);
c = (*p)(a, b); //通过函数指针调用Max函数
printf("a = %d\nb = %d\nmax = %d\n", a, b, c);
return 0;
}
int Max(int x, int y) //定义Max函数
{
int z;
if (x > y)
{
z = x;
}
else
{
z = y;
}
return z;
}
指针变量本身也是存在内存的数值,那么指向指针变量的地址就是二级指针,有些面试题喜欢拿这个“套娃”知识点做题,我就不详述了。
在讲函数之前,我简单介绍一下makefile,makefile定义了一系列的规则来指定哪些文件需要先编译,哪些文件需要后编译,哪些文件需要重新编译,甚至于进行更复杂的功能操作。下面是一个makefile的最简单形式,它告诉编译make工具使用maic.c和tool.c来生成mytool可执行文件:
OBJS=main.o tool.o
CC=gcc
RM=rm -f
CFLAGS+=-c -Wall -g
mytool:$(OBJS)
$(CC) $^ -o $@
%.o:%.c
$(CC) $^ $(CFLAGS) -o $@
clean:
$(RM) *.o mytool -r
下面是一个函数的定义,它在tool.c中定义,它的形式如下图所示:
#include <stdio.h>
#include "tool.h"
void mytool()
{
printf("tool print\n\n");
}
下面是main.c的内容,main()函数调用了mytool()函数:
#include <stdio.h>
#include <stdlib.h>
#include "tool.h"
int main()
{
mytool();
exit(0);
}
函数的传参分为通常所用的值传递,即为函数内部使用的入参为原始数据的副本,还有地址传递,这个时候就可以通过地址来改变原始数据的值。
函数的调用分为正常的嵌套调用和递归调用,嵌套调用就是函数调用别的函数再调用别的函数,嵌套的级数越多对系统资源消耗越大。递归调用就是自己调用自己。
数组与构造类型
数组作为最常见的构造类型。下图是一个二维数的形式:
下面是利用二维数组来实现的矩阵乘法:
#include <stdio.h>
#include <stdlib.h>
#define M 2
#define N 3
#define K 2
static void multiply()
{
int a[M][N] = {1,2,3,4,5,6};
int b[N][K] = {1,0,0,1,1,0};
int c[M][K] = {0};
for(int i = 0; i < M; i++)
{
for(int j = 0; j < K; j++)
{
for(int k = 0; k < N; k++)
{
c[i][j] += a[i][k] * b[k][j];
}
}
}
for(int i = 0; i < M; i++)
{
for(int j = 0; j < K; j++)
{
printf("%d\t", c[i][j]);
}
printf("\n");
}
return;
}
int main()
{
multiply();
exit(0);
}
C标准库提供了一系列字符数组的操作函数,包括:strlen & sizeof; strcpy & strncpy;strcat & strncat;strcmp & strncmp。
我们常见的构造体类型是结构体,当遇到大型软件的时候,最难梳理的往往就是结构体之间的关系。下面是一个结构体的例子,非常简单我就不说语义了,我提一个问题,最后打印的两个sizeof分别是多少?加不加"__attribute__((packed))"对结果有什么影响?知道答案的可以在评论区留言。
struct simple_st
{
int i;
float f;
char ch;
}; //__attribute__((packed));
void func(struct simple_st b)
{
printf("size of b = %lu\n", sizeof(b));
}
void func2(struct simple_st *p)
{
printf("size of p = %lu\n", sizeof(p));
}
int main()
{
//TYPE NAME = VALUE;
struct simple_st a;
struct simple_st *p = &a;
func(a);
func2(p);
}
比较少用到的就是共用体了,它是允许你通过不同的结构去理解同一片存储区域。你可以看到这个共用体的大小由最大的成员决定。
#include <stdio.h>
#include <stdlib.h>
union test_un
{
int i;
float f;
double d;
char ch;
};
int main()
{
union test_un a;
union test_un *p = &a;
union test_un arr[3];
a.f = 345.678;
printf("size of a = %lu\n", sizeof(a));
printf("a.f = %f\n", a.f);
printf("p->f = %f\n", p->f);
//printf("a.i = %d\n", a.i);
exit(0);
}
共生体一般会和位域配合使用,能够给使用者提供针对某一个完整数据类型其中某个位段数据的操作,这在车载领域的CAN报文应用广泛,我们可以根据DBC定义的Message编写对应的联合体定义,针对其不同的Signals赋值之后就可以通过CAN控制器发送报文。
union add_un
{
struct
{
unsigned short a;
unsigned short b;
}n;
unsigned int c;
};
int main()
{
unsigned int i = 0x11223344;
union add_un a;
//printf("0x%x\n", ((i >> 16) + (i & 0x0000FFFF)));
a.c = i;
printf("0x%x\n", a.n.b);
printf("0x%x\n", a.n.a);
printf("0x%x\n", a.n.a + a.n.b);
exit(0);
}
最后,聊聊枚举。不要以为枚举有什么好讲的,如果你阅读过Linux内核的代码,你就会发现它大量的使用了宏定义以及枚举等预编译技术,它们虽然会带来编译时长的增加,但是却会带来诸如易读以及执行速度的优势。
#include <stdio.h>
#include <stdlib.h>
enum day
{
MON = 1, //默认从0开始往下排
TUS,
WES,
THR,
FRI = 1,
SAT,
SUN
};
enum //当作宏来使用
{
STATE_RUNNING = 1,
STATE_CANCELLED,
STATE_OVER
};
struct job_st
{
int id;
int state;
time_t start, end;
};
int main()
{
struct job_st job1;
job1.state = STATE_OVER;
switch (job1.state)
{
case STATE_RUNNING:
break;
case STATE_CANCELLED:
break;
case STATE_OVER:
break;
default:
break;
}
#if 0
enum day a = SUN;
a = TUS;
printf("%d\n", a);
#endif
exit(0);
}
十六宿舍 原创作品,转载必须标注原文链接。
©2023 Yang Li. All rights reserved.
欢迎关注 『十六宿舍』 ,大家喜欢的话,给个 👍 ,更多关于嵌入式相关技术的内容持续更新中。