目录
第二阶段:C 基础
<一>:一维数组
1. 声明
2.初始化
3:遍历数组
4: 数组名 = 首元素地址
<二>:二维数组
1: 声明
2:初始化
3:遍历
4: 二维数组名
①:二维数组名matrix
②:matrix[0]
③:matrix[0][0]
总结:
<三>:字符串
1:字符串的本质(char 数组)
2:char 数组 vs 字符串字面量
①:char str[] = "Hello"
②:char *str = "Hello"
③:总结
④:字面量的应用
3:'\0' 结束符详解
①:忘了预留位置
②:手动构造字符串时忘了写 '\0'
③:strncpy 不自动加 '\0'(重点!)
<四>:常用字符串函数
①:strlen — 获取字符串长度
②:strcpy / strncpy/ strlcpy 复制字符串
1:strcpy 是干什么的?
2:strcpy 最大的问题是什么
3:为什么叫“写穿栈”?
4:strncpy改进strcpy
5:为什么 strncpy 这么设计?
6:为什么嵌入式推荐strncpy这个写法?
7:那 strlcpy 是什么?
③:strcmp / strncmp 比较字符串
1:strcmp 是干什么的?
2:strncmp 是什么?
3:为什么嵌入式特别喜欢 strncmp?
④:strcat 拼接字符串
1:它的作用
2:为什么危险?
3:嵌入式中推荐 snprintf?
4:strncpy + strlen 拼接代替strcat
⑤:sprintf — 格式化输出到字符串
⑥:snprintf — 嵌入式里的超级英雄
五:格式化输入:sscanf
1:基本用法
2:为什么嵌入式里 sscanf / snprintf 比 scanf / printf 更常用
3:sscanf 实战示例
4:sscanf 注意事项
六:综合总结
第二阶段:C 基础
<一>:一维数组
1. 声明
类型 数组名[元素个数];
int arr[10]; // 声明一个能放 10 个 int 数据的数组
这几句话合起来,其实是在教你如何在C语言(特别是嵌入式领域)安全地使用数组:
1:元素个数必须是编译期常量(C89/C90),C99 起支持变长数组(VLA),但嵌入式开发中几2:几乎不用 VLA——栈空间宝贵,不确定性是致命的。比如 int arr[n];
3:数组在栈上分配(如果声明在函数内),占用 sizeof(类型) * 元素个数 字节。
4:声明后不会自动初始化为 0(和全局变量不同),里面是垃圾值
int arr[10];
假设 sizeof(int)是 4 字节。
那么占用的内存就是:4 * 10 = 40字节。
这40字节紧紧挨在一起,构成了数组。
2.初始化
int a[5] = {1, 2, 3, 4, 5}; // 完整初始化 int b[5] = {1, 2}; // 部分初始化 → {1, 2, 0, 0, 0} int c[5] = {0}; // 全部初始化为 0 int d[] = {1, 2, 3}; // 自动推导长度 → d 长度为 3 // 嵌入式常见写法: uint8_t buffer[256] = {0}; // 清零整个缓冲区陷阱:
int e[5] = {0};这只能把第一个元素设为 0,但因为花括号初始化器的规则,未指定的元素会被隐式清零——整个数组确实是 0,但原理要搞清楚。- 不能在声明之外用
arr = {1,2,3}整体赋值,这是语法错误。
int arr[3];
arr = {1, 2, 3};
❌ 错误!数组名是常量地址,不能作为左值被赋值
3:遍历数组
int a[5]; for(i=0;i<5;i++) { printf("%d",a[i]); }编译器分配了连续的 5 个int空间。a[0] ~a[4],没有a[5]
试图访问第 6 个元素。就像一排房子门牌号是 1-5,你去敲 6 号门,里面虽然可能有人住,但肯定不是这排房子的合法住户。
可能会读到脏数据,逻辑错误 / 破坏其他变量,逻辑混乱 / HardFault系统崩溃
4: 数组名 = 首元素地址
这是 C 语言最核心的概念之一:
int arr[5]; printf("%p\n", arr); // = &arr[0] printf("%p\n", &arr[0]); // 和上面完全一样arr表达式的值 = 数组首元素的地址,类型是int*。&arr[0]同样是首元素地址,类型也是int*。&arr和上面两个的数值一样,但类型不同——它是int (*)[5](指向整个数组的指针),&arr + 1会跳过整个数组(跳过 5 个 int)。
为指针铺垫的核心思想:arr[i]本质上就是*(arr + i),编译器把下标操作翻译成指针算术。后面学指针时会反复用到这个等价关系
解释:
arr:数组的首地址(第 0 个元素的地址)。
i:偏移量(第几个元素)。
arr + i:这不是简单的数学加法。这是指针算术。意思是“从arr开始,向后跳过i个‘格子’”。
*: 解引用。意思是“把那个地址里存的数给我取出来”。
arr[i]在编译器的眼里,根本就不是什么“数组操作”,它就是“去那个计算出来的地址拿数据”。
int arr[5] = {10, 20, 30, 40, 50};
arr[0]=*(arr + 0) = 10
arr[1]=*(arr + 1) = 20
arr[2]=*(arr + 2) = 30
arr[3]=*(arr + 3) = 40
arr[4]=*(arr + 4) = 50
<二>:二维数组
1: 声明
int matrix[3][4]; // 3 行 4 列内存布局是行优先(row-major):同一行的 4 个元素在内存中连续存放,然后是下一行。matrix[0][0], matrix[0][1], ..., matrix[2][2], matrix[2][3]依次排列。
2:初始化
int a[2][3] = { {1,2,3}, {4,5,6} }; // 逐行初始化 int b[2][3] = { 1,2,3,4,5,6 }; // 等价,按行优先填充 int c[2][3] = { {1}, {4} }; // 每行其余补 0 int d[][3] = { {1,2,3}, {4,5,6} }; // 行数可省,列数不能省关键规则:只能省略第一维的大小,第二维必须明确——编译器需要知道每一行是多少个元素才能正确计算偏移
3:遍历
int matrix[3][4] for (int i = 0; i < 3; i++) { for (int j = 0; j < 4; j++) { printf("%d ", matrix[i][j]); } printf("\n"); }4: 二维数组名
int matrix[3][4]
核心思想:C语言没有真正的“二维数组”
二维数组不是"里面全是int",而是"里面放着三个一维数组"。
①:二维数组名matrix
当数组名参与表达式时,会退化(decay)成指针。
指向一个包含4个int数组的指针,即int (*)[4]
②:matrix[0]
第一行第一个元素
③:matrix[0][0]
二位数组第一个数字
总结:
<三>:字符串
1:字符串的本质(char数组)
C 没有专门的字符串类型。字符串就是一个以'\0'(NUL,数值为 0) 结尾的char数组
字符: 'H' 'e' 'l' 'l' 'o' '\0' 下标: 0 1 2 3 4 5'\0'的重要性怎么强调都不过分:
所有标准库字符串函数(strlen,strcpy,strcmp等)都靠'\0'来判断字符串结束。忘了它会导致:
strlen一直数下去,读到数组外strcpy疯狂拷贝,把栈写穿- 嵌入式设备死机、看门狗复位、Flash 被意外改写
这是嵌入式 C 开发排第一的 bug 来源。
2:char 数组 vs 字符串字面量
①:char str[] = "Hello"
// 方式一:char 数组(可修改) char str1[6] = {'H', 'e', 'l', 'l', 'o', '\0'}; // 手动逐字符 char str2[] = "Hello"; // 等价,编译器自动加 '\0' char str3[20] = "Hello"; // 剩余 14 字节自动填 0char str[] = "Hello"是"拷贝了一份字符串";
编译器实际干了两件事
申请6个字节:地址0x100~ 0x105
然后把 Hello\ 0 全部复制进去
修改时,令str[0]='A';
②:char *str = "Hello"
很多人以为编译器也是内存然后创建数组,其实不是。
编译器通常会先在程序里面放一份固定的数据:
然后我们创建的字符指针变量str里面存放地址,0x5000,指向该字符串的地址
printf("%s",str);得到0x5000
为什么不能修改?
如果写代码,str[0]='A'; 所代表的意义就是把地址0x5000中,首元素H改为A,
但是0x5000属于程序只读区(.rodata),CPU通常不给写,报错HardFault
为什么要放只读区?
因为程序里面可能写很多次"Hello" ,printf("Hello");
char *a="Hello"; char *b="Hello"; char *c="Hello"; 他只需要保存一份然后abc指针指向同一个地址接口即可
三个人共同指向这一份。所以不能改。否则,a改了,b变了,c也变了,程序不合理
因此干脆放到只读区谁都不能改。
为什么嵌入式特别强调这一点?
③:总结
④:字面量的应用
3:'\0'结束符详解
这是所有字符串 bug 的根源,值得多讲几句。
①:忘了预留位置
char buf[5] = "Hello"; // ❌ 数组太小!"Hello" = 5个字符 + 1个'\0' = 6字节 // 这里没有空间放 '\0',strlen(buf) 会越界②:手动构造字符串时忘了写 '\0'
char buf[10]; buf[0] = 'O'; buf[1] = 'K'; // ❌ 没有 buf[2] = '\0'; printf("%s", buf); // 打印 "OK" 后又打印一堆垃圾,直到碰巧遇到一个 0③:strncpy 不自动加 '\0'(重点!)
char dst[5]; strncpy(dst, "Hello World", 5); // dst 现在是 {'H','e','l','l','o'} — 没有 '\0'!!! // strlen(dst) → 越界,灾难strncpy用于复制固定长度字符数组
工业协议用字符串判断的话,就是通信协议出错了,程序错乱不符合对应响应了。
<四>:常用字符串函数
①:strlen— 获取字符串长度
- 从
s开始逐字节扫描,遇到'\0'停止。 - 返回的是字符个数(不含 '\0')。
- 时间复杂度 O(n),不要在循环条件里频繁调用!
不推荐的写法是
for (int i = 0; i < strlen(s); i++) { ...因为每一次循环,他都会 重新扫描整个字符串后再判断循环条件是否满足,随后进入循环
写成:
size_t len = strlen(s); for (int i = 0; i < len; i++) { ... }②:strcpy/strncpy/strlcpy复制字符串
1:strcpy 是干什么的?
char *strcpy(char *dest, const char *src); 指的是 把 src 指向的字符串,完整复制到 dest,完整复制包括最后的\0。
例如:
dest[6] ~ dest[19]没有被修改。后面14个字节内容不动,14字节保持原来的状态。
2:strcpy 最大的问题是什么
比如:
char dest[5];
strcpy(dest,"Hello");
"Hello"需要6字节,但是dest字符数组只有5个位置,
可能写到了栈上的其他变量,这就叫缓冲区溢出(buffer overflow)。
3:为什么叫“写穿栈”?
name太小:H e l l o W o r l d,一直写可能覆盖 flag ,返回地址 上面
所以叫:写穿栈,严重情况下甚至可以控制程序流程。
4:strncpy改进strcpy
复制了5个字符hello 到 dest 中,没有空间就不会加上\0了,所以它不是字符串。
5:为什么 strncpy 这么设计?
因为它原本不是为了字符串,它是为了:固定长度字段
6:为什么嵌入式推荐strncpy这个写法?
例如:
src:ABCDEFGHIJKLM
strncpy(dest,src,9); 复制了A B C D E F G H I ,随后dest[9]='\0';
强行封口:A B C D E F G H I \0 永远合法。
7:那 strlcpy 是什么?
strncpy 是反人类涉及的一个函数
strlcpy(dest,src,size);
得到了H e l l \0 自动截断后补\0。
③:strcmp/strncmp比较字符串
1:strcmp 是干什么的?
比较两个字符串是否相同
完全一样时,返回0 ,不相同时,返回非0值 (有的小于0 有的大于0)
2:strncmp 是什么?
比如:
strncmp("Hello","Help",3);
返回0 ,后面的: 没有比较。
3:为什么嵌入式特别喜欢 strncmp?
因为很多通信不是完整字符串,比如串口接收AT+RST,这是 ESP8266/ESP32 常见 AT 命令
用于接收比较,但是实际接收AT+RST\r\n,那么此时strcmp的比较返回值就对不上,
可以实现较好的比较功能来实习通信的协议。
④:strcat 拼接字符串
1:它的作用
把一个字符串追加到另一个字符串的末尾,
2:为什么危险?
但是它的危险点也和strcpy()一样:它完全不知道目标数组有多大。
写到了别人的内存。
3:嵌入式中推荐 snprintf?
strcpy和 strcat是以前常用的函数,但它们有个大毛病:它们不知道你的内容有多大。
为了解决这个“不知道蛋糕有多大”的问题,C语言标准后来引入了snprintf 函数。它的特点是:自带保险,绝不溢出
snprintf(buf, sizeof(buf), "Speed=%d", 100);snprintf是怎么干的?
拿到图纸:看到你要拼成 "Speed=100"。
测量盒子:看到你告诉它盒子只有 20 个格子大。
小心翼翼地装:它开始往盒子里放字符 'S', 'p', 'e', 'e', 'd', '=', '1', '0', '0'...
绝不让溢出:如果在装的过程中发现剩下的格子不够放了,它会自动停止,绝不会硬塞导致溢出。
如果数组长度不够,那么最后一个字符一定是放\0,其余字符就截断了。
4:strncpy + strlen 拼接代替strcat
⑤:sprintf— 格式化输出到字符串
把格式化内容写入字符数组:
char buf[50]; sprintf(buf, "ADC: %d mV, Temp: %d.%d C", adc_val, temp / 10, temp % 10);问题同 strcpy:不检查边界!
char buf[10]; sprintf(buf, "Hello World from sensor"); // ❌ 写穿 10 字节!嵌入式里基本不用sprintf
⑥:snprintf— 嵌入式里的超级英雄
它在嵌入式里面非常常用,因为它同时解决两个问题:
- 格式化数据
- 防止缓冲区溢出
sizeof(buf)=10,所以最多写9个字符 + 1个\0,不会导致写入的数据越界。
和 sprintf 的唯一区别就是多了一个size参数,保证不会写超出size-1个字符,且一定会以'\0'结尾。
返回值:可以用来判断缓冲区是否够用
五:格式化输入:sscanf
1:基本用法
int sscanf(const char *str, const char *format, ...);从字符串中按格式读取数据,返回值是成功匹配并赋值的变量个数。
2:为什么嵌入式里 sscanf / snprintf 比 scanf / printf 更常用
sscanf()这一部分其实比前面的strlen/strcpy更接近嵌入式实际开发,因为它解决的是:
把收到的一串文本,按照规则拆成变量。
你之前学 UART 数据包时,其实已经接近这个思想:
串口收到:"AT+CSQ: 17,99"
3:sscanf 实战示例
int sscanf( const char *str, const char *format, ... );
第一个参数:要解析的字符串,比如"123 456"
第二个参数:解析规则,比如"%d %d"
第三个参数:保存结果的变量地址,&a&b
例1:GPS模块输出$GPGGA,123519,4807.038,N,01131.000,E,...这是 GPS 的标准格式。
含义:123519→12点35分19秒
4807.038→纬度
N→北纬
01131.000→经度
E→东经
sscanf( nmea, "$GPGGA,%2d%2d%2d,%f,%*c,%f,%*c", &hour, &min, &sec, &lat, &lon );$GPGGA匹配 $GPGGA之后,%2d%2d%2d继续接收123519
这样子就把时间给解析到 hour=12,min=35,sec=19
例2:
// 3. 解析 AT 命令响应 // +CSQ: 17,99 int rssi, ber; int matched = sscanf(response, "+CSQ: %d,%d", &rssi, &ber); if (matched == 2) { // RSSI = 17, BER = 99 (99 表示不可检测) }例3:
// 4. 解析十六进制 uint32_t addr; sscanf("0x08002000", "%x", &addr); // addr = 0x08002000 // 5. 跳过不需要的内容 sscanf("Sensor ID:42 Temp:250", "Sensor ID:%d %*s %*s", &id); // %*s 跳过一整个字符串(到空白为止)4:sscanf 注意事项
// ❌ 错误:%d 对应 int*,不能传 char 或 short 的地址 char c; sscanf("65", "%d", &c); // 把 4 字节 int 写到 1 字节 char 里,栈破坏! // ✅ 正确:用正确的格式符 int val; sscanf("65", "%d", &val); c = (char)val;