从0到1的硬件学习01 C语言语法基础

本篇是 C 语言语法基础的速览,从一个最简单的 C 程序出发,依次介绍基本数据类型、运算符、分支与循环、函数、数组、结构体、枚举和 typedef 等内容,为后续嵌入式开发打基础。

第一个C程序

先来看一个最简单的C程序:

1
2
3
4
5
6
7
#include <stdio.h>

int main(void)
{
printf("Hello C!\n");
return 0;
}

程序输出Hello C!,虽然只有几行代码,但是已经包含几个C语言最基本的概念了。

#include <stdio.h>是什么

这一行#include <stdio.h>表示

将标准输入输出相关的声明引入当前源文件。

这里使用到的printf()就是标准库提供的输出函数。可以简单理解为stdio.h告诉编译器printfscanf函数是什么。

理解int main(void)

int main(void)可以拆成:

  • int -> 返回类型
  • main -> 函数名称
  • (void) -> 参数列表

int 表示这个函数最终返回一个整数,比如return 0;这里返回0就是一个int。通常约定0表示程序正常结束,非0通常表示发生错误。

main这是程序的入口函数,程序运行以后,会从main()开始执行。

void它是一个类型关键字,在这里表示这个函数不接收任何参数,所以int main(void)完整的解读就是定义一个名为main的函数,它不接收参数,并返回一个int另外void还可以表示”没有返回值”,比如:

1
2
3
4
void hello(void)
{
printf("hello\n");
}

这里出现了两个void,前一个void表示不返回任何值,后一个void表示不接收参数。可以理解为输入无,输出无。而int add(int a, int b)表示输入为两个int,输出为一个int

基本的数据类型

C语言中最常见的数据类型包括:

  • char
  • short
  • int
  • long
  • float
  • double

例如:

1
2
3
4
5
6
7
int age = 20;

float temperature = 36.5f;

double pi = 3.1415926;

char grade = 'A';

这里需要注意的地方'A'表示一个字符,而"A"表示字符串,这两者并不是一个东西。

signedunsigned

整数类型可以分成:signedunsigned,例如:

1
2
3
int a = -10;

unsigned int b = 10;

unsigned表示这个整数没有负数范围。

固定宽度整数

普通的intlongshort在不同平台上的大小并不一定完全一致。因此在开发中经常会使用:

1
2
3
4
5
#include <stdint.h>

uint8_t level = 255;
uint8_t adc_value = 4095;
uint32_t counter = 100000;

uint8_t为无符号8bit,uint16_t为无符号16bit,uint32_t为无符号32bit,int32_t为有符号32bit。这在操作硬件寄存器、传感器数据、通信协议时非常有用。

sizeof

C语言可以使用sizeof来查看某个类型或者对象占用多少字节。比如:

1
2
3
printf("%zu\n", sizeof(char));
printf("%zu\n", sizeof(int));
printf("%zu\n", sizeof(double));

在很多现代电脑上可能输出为:

1
2
3
1
4
8

**但不要永远假设int一定是4字节。**这就是为什么在嵌入式场景下经常更喜欢使用uint32_t这种明确指定宽度的类型。

使用printf

C语言最常用的输出函数就是printf(),比如:

1
2
int age = 20;
printf("age = %d\n", age);

常见格式符:

格式 含义
%d int
%u unsigned int
%f 浮点数
%c 字符
%s 字符串
%x 十六进制整数
%z 长度修饰符,专门用于格式化输出size_t或者ssize_t类型的数据

比如:

1
2
3
4
int value = 255;

printf("%d\n", value);
printf("%x\n", value);

输出:

1
2
255
ff

在嵌入式开发中,经常需要输出十六进制:printf("reg = 0x%x\n", reg);因为硬件寄存器通常就是以十六进制形式查看的。

变量

C的变量需要先指定类型:int x;然后赋值:x = 10;,也可以直接初始化:int x = 10;,比较推荐每个变量单独写:

1
2
3
int a = 1;
int b = 2;
int c = 3;

虽然C语言也允许: int a = 1, b = 2, c = 3;但是前一种形式通常更容易阅读和维护。

const

如果一个值初始化以后不应该再改变,可以使用:const int max_count = 100;之后不能max_count = 200;目前可以简单把const理解成:

这个对象不应该再被修改

需要注意的是,const和指针组合以后会出现更复杂的情况:

1
2
const int *p;
int *const p;

这些会放在后面介绍。

算符运算符

常见算术运算符和大多数语言一致:+ - * / %。比如:

1
2
3
4
5
6
7
8
int a = 10;
int b = 3;

printf("%d\n", a + b);
printf("%d\n", a - b);
printf("%d\n", a * b);
printf("%d\n", a / b);
printf("%d\n", a % b);

整数除法

这里有一个非常重要的细节: 10 / 3结果是3,而不是3.333333。这是因为10和3都是整型,所以得到的也是整型。如果希望得到浮点数,可以使用10.0 / 3.0,又或者使用:

1
2
3
4
int a = 10;
int b = 3;

double result = (double)a / b;

这里的(dobule)a表示显式类型转换。

自增与自减

C提供自增与自减的方式:

1
2
3
4
5
i++;
i--;

++i;
--i;

最常见的场景是: for (int i = 0; i < 10; i++) {},如果只是单独写i++++i最终结果通常一样。但是下面两种写法不同:int a = i++; int a = ++i;一个是先使用再增加,另一个是先增加再使用。

比较运算符

C的比较运算符包括:

1
2
3
4
5
6
==  表示相等
!= 表示不相等
> 表示大于
< 表示小于
>= 表示大于等于
<= 表示小于等于

比如if (age >= 18){},需要注意的是=表示赋值,而==才表示比较。

逻辑运算

逻辑运算符:

1
2
3
&& 代表与,即两者都为真,结果才为真
|| 代表或,即两者中有一个为真,结果就为真
! 代表非,即取反,如果原值是真,结果为假,原值为假,结果为真

通常在判断中用的比较多,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (age >= 18 && age < 60)
{

}

if (is_admin || is_owner)
{

}

if (!enabled)
{

}

布尔值

传统C语言中,0表示false非0表示true。比如:

1
2
3
4
5
int value = 5;

if (value) {
printf("true\n");
}

这里会进入if,现代版本的C可以:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdbool.h>
#include <stdio.h>

int main(void)
{
bool running = true;

if (running)
{
printf("running\n");
}

return 0;
}

分支、循环

if/else

1
2
3
4
5
6
7
8
9
10
11
12
13
int age = 20;

if (age < 18) //如果判断条件成立,就会执行后面的代码
{
printf("child\n");
}
else if (age < 60) //如果if条件不成立的话,就会执行这里的判断条件,多个else if可以叠加
{
printf("adult\n");
}
else { //如果所有的判断条件都不成立的话,走这个分支
printf("senior\n");
}

switch

如果多个判断条件指向同一个变量的话,可以使用switch来精简if/else的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int command = 2;

switch (command)
{
case 1:
printf("start\n");
break;

case 2:
printf("stop\n");
break;

case 3:
printf("reset\n");
break;

default:
printf("unknown\n");
break;
}

这里需要注意,break;如果漏掉的话,就变成了:

1
2
3
4
5
6
7
8
switch (x) 
{
case 1:
printf("one\n");

case 2:
printf("two\n");
}

x == 1时,可能输出:

1
2
one
two

因为程序没有在匹配到条件的时候跳出分支,会继续执行下一个case,这种行为叫fallthrough

写一个switch-case最好都带上default这个case,因为你无法保证一定不会出现什么漏掉的情况,此时default分支可以给你进行兜底。

while

当需要在满足某个条件时,一直执行某个操作,可以使用while

1
2
3
4
5
6
7
int i = 0;

while (i < 5)
{
printf("%d\n", i);
i++;
}

此时会输出:

1
2
3
4
5
0
1
2
3
4

格外注意的是,必须要在执行体内让循环判断条件不满足,不然的话会导致死循环。

do while

while是判断满足条件后执行某些操作,do while是先执行某些操作再进行条件判断。

1
2
3
4
5
6
itn i = 0;

do {
printf("%d\n", i);
i++;
} while (i < 5);

for

C语言中最常见的循环结构,可以使用for:

1
2
3
4
for (int i = 0; i < 10; i++) 
{
printf("%d\n", i);
}

for循环的结构是for (初始化执行; 循环判断条件; 每轮循环后执行),上面的代码流程表示为:

  1. 初始化时声明一个变量i,并初始化为0
  2. 判断循环条件i是否小于10,如果小于那么执行循环体,如果大于等于,停止循环
  3. 然后开始执行循环体,输出当前的i
  4. 循环结束后,让变量i自增
  5. 再次判断循环条件,执行循环体,直到循环条件不满足

break和continue

正常循环,需要等待循环条件不满足时才会停止循环,而breakcontinue可以提前结束循环。

1
2
3
4
5
6
7
8
9
for (int i = 0; i < 10; i++)
{
if (i == 5)
{
break;
}

printf("%d\n", i);
}

输出:

1
2
3
4
5
0
1
2
3
4

break关键字可以直接跳出整个for循环。而continue可以用来跳过当前那一轮循环:

1
2
3
4
5
6
7
8
9
for (int i = 0; i < 10; i++)
{
if (i == 5)
{
continue;
}

printf("%d\n", i);
}

结果为:

1
2
3
4
5
6
7
8
9
0
1
2
3
4
6
7
8
9

函数

C函数需要写明:返回类型、函数名和参数类型,比如:

1
2
3
4
int add(int a, int b)
{
return a + b;
}

调用int result = add(10, 20);此时这里的10和20会作为参数放入add函数中,然后执行函数体将返回值赋值给result变量。

没有返回值的函数

如果函数没有返回值,可以这么写:

1
2
3
4
void say_hello(void)
{
printf("hello\n");
}

这里前面的void是返回值类型,表示没有返回值。后面括号里的void表示没有参数。也可以直接写成void say_hello(),但是有一点区别,这个表示不指定参数。

值传递

C中默认是值传递,比如下面这个例子:

1
2
3
4
5
6
7
8
void change(int x)
{
x = 100;
}

int a = 10;
change(a);
printf("%d\n", a);

输出依旧是10,这是因为在调用change函数的时候,a把值复制了一份给了x,而不是将自己放入到change中,所以这里被修改的是得到赋值后的x,而不是原本的a。如果想要函数能够修改外部的变量,那么就需要使用到指针

函数声明与定义

1
2
3
4
5
6
7
8
//这是函数声明
int add(int a, int b);

//这是函数定义
int add(int a, int b)
{
return a + b;
}

函数声明只确定了函数名以及返回值类型、参数,并不关心函数是什么实现的。而函数定义需要明确的函数体。区分这两者在学习.h.c文件时会非常重要。

数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//创建一个包含5个整数的数组:
int numbers[5] = {
10,
20,
30,
40,
50
};

//可以让编译器推断长度
int numbers[] = {
10,
20,
30
};

数组创建时,int代表数组中存放的每个元素的类型,变量名后面的[]代表声明的变量是一个数组,[5]中的数字代表数组长度。数组要求内部的每个元素的类型都保持一致。

数组下标

数组下标从0开始,比如:

1
2
3
numbers[0]
numbers[1]
numbers[2]

一个长度为5的数组,最后一个元素是numbers[4]

遍历数组

1
2
3
4
5
6
7
8
9
10
11
12
int numbers[] = {
10,
20,
30,
40,
50
};

for (int i = 0; i < 5; i++)
{
printf("%d\n", numbers[i]);
}

但是最好不要把5硬编码进去,可以这么写:

1
2
3
4
5
6
int length = sizeof(numbers) / sizeof(numbers[0]);

for (int i = 0; i < length; i++)
{
printf("%d\n", numbers[i]);
}

这个的原理是整个数组占用的字节数/一个元素占用的字节数=元素数量,比如20bytes / 4bytes = 5

数组越界

1
2
3
4
5
6
7
int numbers[3] = {
1,
2,
3
};

printf("%d\n", numbers[100]);

C语言不会像Java或者Python那些语言可靠地给一个清晰的越界异常。可能出现的是: 垃圾值、程序崩溃、其他内存被破坏、看似什么都没发生。这也是C非常核心的特点:

C给程序员很大的控制权,同时也给程序员更多责任。

二维数组

遍历二维数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int map[2][3] = {
{1, 2, 3},
{4, 5, 6}
};

for (int y = 0; y < 2; y++)
{
for (int x = 0; x < 3; x++)
{
printf("%d ", map[y][x]);
}

printf("\n");
}

struct把多个字段组合起来

struct是C中非常重要的一种数据结构。比如:

1
2
3
4
5
struct Player {
int hp;
int mana;
int level;
};

可以把它理解成一个纯数据结构,一个叫Player的结构,它有三种属性hpmanalevel。创建Player可以使用:struct Player player;,赋值可以使用:

1
2
3
player.hp = 100;
player.mana = 50;
player.level = 1;

这样就将按照Player这种结构创造出来的实体,即player的每个属性都填充了值。读取也是类似的:printf("%d\n", player.hp);

创建实例的时候进行初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Player player = {
.hp = 100,
.mana = 50,
.level = 1
};


//也可以使用
struct Player player = {
100,
50,
1
};

//当然上一种写法更加容易阅读,尤其当字段数量越来越多的时候,上一种写法更能表明每个值对应什么字段。

struct可以进行嵌套

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct Point 
{
int x;
int y;
};


struct Rectangle
{
struct Point position;
int width;
int height;
};

//初始化的时候
struct Rectangle rect = {
.position = {
.x = 10,
.y = 20
},
.width = 100,
.height = 50
};


//访问
printf("%d\n", rect.position.x);

struct数组

结构体也可以组成数组,比如有这样的一个结构体Player:

1
2
3
4
5
struct Player
{
int hp;
int level;
}

然后构建结构体数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Player player[3] = {
{
.hp = 100,
.level = 1
},
{
.hp = 200,
.level = 5
},
{
.hp = 300,
.level = 10
}
};

访问:players[1].hp

enum一组有名字的整数

有时候我们需要存储某些有限个数带有含义的值,那么可以使用enum,比如:

1
2
3
4
5
6
enum State
{
STATE_IDLE,
STATE_RUNNING,
STATE_STOPPED
};

默认情况下:

1
2
3
STATE_IDLE    = 0
STATE_RUNNING = 1
STATE_STOPPED = 2

在这种使用方式下enum State state = STATE_IDLE;比直接写int state = 0;更加容易被理解,毕竟看到0并不知道代表什么意思,而STATE_IDLE一眼就是知道这表示待机。

enum可以指定具体的数值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum Error
{
ERROR_NONE = 0,
ERROR_TIMEOUT = 10,
ERROR_IO = 20
};

//又比如在嵌入式中经常可以看到

enum PinState
{
PIN_LOW = 0,
PIN_HIGH = 1
};

enumswitch组合使用

状态机经常使用这种组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
enum State
{
STATE_IDLE,
STATE_RUNNING,
STATE_ERROR
};

enum State state = STATE_RUNNING;

switch (state)
{
case STATE_IDLE:
printf("idle\n");
break;

case STATE_RUNNING:
printf("running\n");
break;

case STATE_ERROR:
printf("error\n");
break;
}

typedef给类型起别名

typedef的作用可以简单理解为给一个类型起一个新的名字。举个简单的例子:

1
2
3
4
5
6
//以下两者是等价的
typedef unsigned int unit;
unit value = 100;


unsigned int value = 100;

typedef + struct

C中非常常见的一种写法:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct 
{
int hp;
int level;
} Player;


//之后仅需要
Player player;

//而不需要
struct Player player;

嵌入式代码经常可以看到,因此这种形式非常值得熟悉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct
{
...
} Config;

typedef struct
{
...
} SensorData;

typedef struct
{
...
} Device;

typedef + enum

同样可以:

1
2
3
4
5
6
typedef enum
{
STATE_IDLE,
STATE_RUNNING,
STATE_ERROR
} State;

之后State state = STATE_IDLE;代码会更加简洁。