Day9的练习做完已经是晚上九点半了。今天的内容看着都是老熟人——倒置、冒泡、选择、二维数组、一维字符数组,但真在嵌入式板子上跑起来,还是踩了不少坑。比如二维数组传参时忘记写列数,编译器直接报错;比如字符数组初始化时少留了一个字节,串口命令解析结果奇奇怪怪;再比如最基础的倒置,我一开始没加临时变量,数据直接被覆盖。这篇日志把今天所有练习代码和避坑过程完整记录下来,给同样在走嵌入式学习路线的朋友做个参考。如果你是零基础刚学到数组,或者准备嵌入式面试想复习排序和数组,这篇应该能帮你少走两三个小时的弯路。
1. 为什么第9天的内容几乎全在“折腾内存”
1.1 嵌入式场景里,数组到底用在哪
很多人在PC上写代码,觉得数组就是个“连续存放的一堆数”,用不用无所谓。但到了嵌入式环境里,数组几乎是无处不在的基础设施。
嵌入式设备和普通程序最大的区别是资源受限,MCU的RAM经常只有几KB到几十KB,Flash也就几十KB到几百KB。你不能像写桌面软件那样随便开几个大的动态数据结构,更不能指望垃圾回收机制帮你管理内存。于是,固定大小的数组就成了嵌入式代码里最常用的数据容器。
今天的练习里我反复在做的几件事,背后都是很典型的嵌入式场景:
- 传感器数据采集:ADC连续采样100次,放进数组,之后做滤波或取平均;
- 通信缓冲区:串口收到一帧数据,先存进字符数组,再解析协议;
- 状态管理:按键矩阵、LED点阵屏,本质上都是二维数组在描述坐标状态;
- 命令表:用字符数组保存AT指令或自定义协议指令,再和收到的数据比对。
数组在嵌入式里不是“练习题”,而是每天都要写的东西。
1.2 排序在单片机工程中的现实意义
那排序呢?很多人第一反应是:单片机里基本用不到排序吧?功能简单,数据量也小,犯不着写个冒泡或者选择。
这个说法有对的部分,也有不对的部分。对的地方是:工业级嵌入式代码里,确实很少会专门写一个排序算法。不对的地方是:排序思想在数据处理里随处可见,面试还爱考,而且有些场合你必须自己排序。
举个例子,今天我在板子上做了一个温度采集练习。DS18B20或者内部ADC采回来的原始数据,会有随机抖动和偶尔的毛刺。常见的处理方式之一是“去掉最大最小值再求平均”,也就是把采集数组排序后,掐头去尾,再对中间数据求平均。这就是个非常典型的排序应用场景,不需要多复杂的算法,冒泡就够用了。
再比如,嵌入式设备记录日志时,常需要按时间戳或者优先级把记录排一下;做用户界面的时候,菜单列表可能要按某种规则展示。这些都用得上排序。
当然,更现实的原因是:嵌入式岗位面试时,手写冒泡或选择排序几乎是保留节目。从热搜词里也能看出来,“嵌入式面试八股文”“嵌入式面试题”里排序算法一直是高频内容。所以不管工作用不用,这关必须过。
1.3 今天的练习会为后面哪些内容铺路
其实Day9最大的价值,不在排序本身,而在于为指针和链表打基础。
数组倒置练习里,传参用的是int arr[],本质上就是一个指向数组首元素的指针。二维数组的a[i][j]访问方式,背后是*(*(a+i)+j)的指针运算。字符数组更是直接牵扯到\0、sizeof、strlen这些让新手头痛的概念。
后面学指针时,会发现今天这些代码里的很多“奇怪写法”都能解释通了。所以不用着急,先把数组这块地基夯结实。
2. 数组倒置:首尾交换的边界条件与经典错误
2.1 倒置的核心思路:两端向中间逼近
数组倒置,就是把数组里的元素顺序反过来。{1, 2, 3, 4, 5}变成{5, 4, 3, 2, 1}。
思路很直白:第一个和最后一个交换,第二个和倒数第二个交换,一直往中间走。也就是用两个变量i和j分别指向数组头和尾,交换元素后i++、j--,直到i >= j时停下。
下面是我今天写的完整代码:
#include <stdio.h> void print_array(int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); } void reverse(int arr[], int n) { int i = 0; int j = n - 1; while (i < j) { int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; i++; j--; } } int main(void) { int a[] = {1, 2, 3, 4, 5}; int b[] = {1, 2, 3, 4, 5, 6}; printf("a原始数据: "); print_array(a, 5); reverse(a, 5); printf("a倒置后: "); print_array(a, 5); printf("b原始数据: "); print_array(b, 6); reverse(b, 6); printf("b倒置后: "); print_array(b, 6); return 0; }实测输出:
a原始数据: 1 2 3 4 5 a倒置后: 5 4 3 2 1 b原始数据: 1 2 3 4 5 6 b倒置后: 6 5 4 3 2 12.2 奇偶长度不会影响循环次数,但影响理解
我最初写这个函数的时候,循环条件用的是while (i <= j)。看着没毛病,但仔细一想,当数组长度为奇数时,最后i和j会指向同一个元素,此时交换是自己和自己交换,纯属多余操作。虽然不会报错,但不够干净。
改成while (i < j)后逻辑更精确:只要左边的下标还小于右边的下标,说明还有未交换的元素对。长度是奇数时,中间那个元素不用动;长度是偶数时,所有元素都会配对交换,最后i和j擦肩而过,循环自然结束。
这个边界条件是最容易出问题的地方,也是最容易忽略的地方。
2.3 交换数据时忘记临时变量
今天的代码里我犯了个低级错误——最开始写的是:
arr[i] = arr[j]; arr[j] = arr[i];这当然不行,第一次赋值后,arr[i]的原始值已经被覆盖了。这也是新手最容易踩的坑之一。
正确做法是引入临时变量tmp:
int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp;可以想象成你要把两个杯子的水互换,必须拿一个空杯子当中转,不能直接倒来倒去。
2.4 倒置在嵌入式里的实际应用场景
数组倒置看起来像纯练习题,但实际工程里还真用得上。前两天我在调一个串口屏显示协议时,上位机发过来的数据包要求低字节在前、高字节在后,而MCU内部是按大端方式处理的,当时就写了个字节序翻转的小函数来处理收发的数据。
再比如,有些传感器模块输出的数据是按“从新到旧”顺序排列的,你要把这些历史数据正序输出,也需要做一次倒置。
字符数组倒置也是字符串反转题的变形,面试里偶尔会问。本质上都一样,掌握了这一套,换什么数据类型都能处理。
3. 冒泡排序:从暴力比较到 flag 优化
3.1 冒泡的核心思想:每一轮把最大值“冒”到最后
冒泡排序是我今天第二个练习项目。它的原理可以这么理解:从数组第一个元素开始,相邻两个数两两比较,如果前面的大于后面的,就交换。这样第一轮结束后,最大的数就像气泡一样“浮”到了数组末尾。
然后第二轮只处理前n-1个元素,再把第二大的数浮到倒数第二个位置。如此循环n-1轮,整个数组就排好了。
我写的标准版代码:
#include <stdio.h> void bubble_sort(int arr[], int n) { // 外层循环:一共需要 n-1 轮 for (int i = 0; i < n - 1; i++) { // 内层循环:每一轮比较的范围在缩小 for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; } } } } void print_array(int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); } int main(void) { int a[] = {64, 34, 25, 12, 22, 11, 90}; int n = sizeof(a) / sizeof(a[0]); printf("排序前: "); print_array(a, n); bubble_sort(a, n); printf("排序后: "); print_array(a, n); return 0; }实测输出:
排序前: 64 34 25 12 22 11 90 排序后: 11 12 22 25 34 64 90特别提醒一下:我在写int n = sizeof(a) / sizeof(a[0]);时,是直接在main函数里对数组用sizeof。如果把数组作为参数传进函数,再在函数内部用sizeof(arr)就不行了,因为函数参数里的int arr[]本质上退化成指针,sizeof(arr)拿到的是指针大小,不是数组大小。这个问题今天虽然没踩,但后面写排序函数时一定会遇到,先记下来。
3.2 为什么内层循环次数是 n - 1 - i
这是新手理解冒泡时最容易卡住的地方。我的理解方式是:每完成一轮排序,数组末尾就会多一个已经归位的元素,所以下一轮就不需要再和它比较了。
外层第i轮开始时,已经有i个元素在数组末尾排好了,剩下待排序的元素个数是n - i个。这n - i个元素,相邻比较只需要进行n - i - 1次。
所以内层循环的j从0到n - 1 - i(注意不包含n-1-i本身)。第一轮比较n-1次,第二轮n-2次,直到最后一轮比较1次。总比较次数是1 + 2 + ... + (n-1) = n(n-1)/2。
3.3 加 flag 的优化版本
标准版冒泡有个明显的问题:如果数组本来就已经有序,它还是会傻乎乎地比较完所有轮次。这在实际工程里浪费电,虽然在单片机里这点耗时不至于天塌,但优化成本极低,为什么不写呢?
优化思路很简单:每一轮开始前设一个swapped标志,如果这一轮里发生过任何交换,说明数组还没排好;要是某一轮从头到尾没有任何交换,说明所有元素都已经有序,直接跳出循环。
void bubble_sort_optimized(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; // 每一轮重置标志 for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = 1; } } // 这一轮没有发生任何交换,说明已经有序 if (swapped == 0) { break; } } }加了flag之后,最好情况(数组原本就有序)的时间复杂度从O(n²)降到了O(n),只需要遍历一遍确认没有交换即可。
3.4 稳定性:嵌入式里也得知道的概念
面试问排序时,“稳定性”是个高频追问点。
稳定排序是指:如果数组中有两个相等的元素,排序之后它们的相对顺序保持不变。
冒泡排序是稳定排序,因为代码里只在arr[j] > arr[j + 1]时才交换,相等时不交换,所以相等元素的相对位置不会被破坏。
稳定性在嵌入式场景里有什么用?举个真实例子:我有一个结构体数组,里面存的是传感器数据,每个元素有时间戳。我先按时间排好序,再按数值排序。如果排序算法不稳定,第二次排序可能把相同数值元素的时间顺序打乱,这样日志显示的先后就错了。
所以面试问稳定性时,不要觉得是在考背诵,它背后是有工程意义的。
4. 选择排序:每轮找最值,和冒泡的取舍
4.1 选择排序的核心思路:每轮选出最小值和最前面交换
选择排序的思路和冒泡完全不同。冒泡是相邻元素两两比较、频繁交换;选择排序是每一轮遍历剩余元素,找到最小值所在的下标,然后只交换一次,把这个最小值放到当前轮次的起始位置。
我的实现代码:
#include <stdio.h> void select_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int min_idx = i; // 假设当前 i 位置的元素是最小的 // 在 i 之后的元素里找真正的最小值下标 for (int j = i + 1; j < n; j++) { if (arr[j] < arr[min_idx]) { min_idx = j; } } // 如果最小值不是当前位置,才交换 if (min_idx != i) { int tmp = arr[i]; arr[i] = arr[min_idx]; arr[min_idx] = tmp; } } } void print_array(int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); } int main(void) { int a[] = {64, 34, 25, 12, 22, 11, 90}; int n = sizeof(a) / sizeof(a[0]); printf("排序前: "); print_array(a, n); select_sort(a, n); printf("排序后: "); print_array(a, n); return 0; }输出结果和冒泡排序一样,但内部执行逻辑完全不同。
4.2 选择排序和冒泡排序的核心差异
我把今天对着代码和运行过程总结的对比表写在这里:
| 对比项 | 冒泡排序 | 选择排序 |
|---|---|---|
| 基本思想 | 相邻元素两两比较,把大值逐步冒泡到末尾 | 每轮选择最小值,交换到当前起始位置 |
| 比较次数 | 固定为 n(n-1)/2 | 固定为 n(n-1)/2 |
| 交换次数 | 最好 n-1 次,最坏 n(n-1)/2 次 | 最多 n-1 次,每轮最多一次 |
| 最好时间复杂度 | O(n)(加了 flag 优化后) | 永远 O(n²) |
| 最坏/平均时间复杂度 | O(n²) | O(n²) |
| 空间复杂度 | O(1) | O(1) |
| 稳定性 | 稳定 | 不稳定 |
从这个表能看出两个关键点:
第一,选择排序的交换次数远小于冒泡。在正常嵌入式硬件里,交换两个RAM变量几乎不消耗什么时间,但如果数据存放在EEPROM或Flash里,频繁写入会减少存储介质的寿命。从“减少写入次数”的角度看,选择排序反而比冒泡更适合某些存储型场景。
第二,加了flag的冒泡在近乎有序的数据上表现更好。比如一个数组只有一个元素顺序不对,冒泡第一轮可能就检测到没有交换,直接退出;选择排序则必须傻乎乎地把所有轮次跑完。
所以不能说谁绝对好,得看场景。
4.3 为什么选择排序是不稳定的
这是今天笔记里一个很重要的知识点。选择排序的不稳定性,体现在它每次交换可能“跨过”多个相等的元素。
举个例子:
数组: [5a, 3, 5b, 2, 1] 下标: 0 1 2 3 4第一轮找最小值,下标4是1,和下标0的5a交换,数组变成[1, 3, 5b, 2, 5a]。此时原数组中的第一个5a被换到了最后,而第二个5b留在原位,两个5的相对顺序已经翻转。这就是选择排序不稳定的直观表现。
在只需要数值排序的场景里,这不影响正确性;但如果你将来对结构体数组按某个字段排序时,就要留意稳定性问题。
4.4 面试里的常见追问
搜索热词里有“嵌入式面试八股文”“冒泡排序算法c++”“选择排序和冒泡排序”,说明这是面试热门。根据我自己的准备经验,面试官一般会按这个顺序追问:
- 手写冒泡排序;
- 还能优化吗?(答:加flag,最好情况变成O(n));
- 手写选择排序;
- 两者区别?(答:交换次数不同);
- 谁稳定?冒泡稳定,选择不稳定,为什么?
这五个问题能答好,排序的基础就算扎实了。今天的练习让我意识到,光会写代码不够,还得把“为什么”讲清楚。
5. 二维数组:本质是一维内存,行优先是C的规则
5.1 二维数组的内存模型
很多人初学二维数组时,会在脑补一个“平面表格”,但实际上,C语言里所有数组在内存里都是线性排列的连续空间。二维数组本质上是“数组的数组”。
比如:
int matrix[3][4];它意味着:有3个元素,每个元素是“一个含有4个int的一维数组”。在内存中的存放顺序是:第0行的4个int,接着第1行的4个int,再接着第2行的4个int。这种存储方式叫行优先。
整个数组占用的字节数是3 * 4 * sizeof(int),在32位系统上通常就是3 * 4 * 4 = 48字节。
如果让我用一个生活化类比的话,可以把二维数组想成一栋公寓楼:楼有三层(行),每层有四个房间(列)。但物理上这些房间是沿着走廊一字排开的,只不过你按楼层和房间号去索引。
5.2 访问 a[i][j] 时,内存到底怎么偏移
我今天的笔记里画了这样一个重点:
a[i][j] 等价于 *(a[i] + j) 也等价于 *(*(a + i) + j)这里a + i是指向“第i行”的指针,解引用后得到第i行的首地址,再加上j个int的位置,最后解引用,就能访问到目标元素。
用地址偏移量来写就是:
地址 = 数组首地址 + (i * 列数 + j) * sizeof(元素类型)这就是为什么函数传参时,二维数组的列数必须明确给出,因为计算元素偏移需要列数参与。
5.3 初始化细节:给少了怎么办
二维数组的初始化可以分行写,也可以连续写。我在今天的练习中验证了几种写法:
// 完全初始化:3行4列全部给值 int a[3][4] = { {1, 2, 3, 4}, {5, 6, 7, 8}, {9, 10, 11, 12} }; // 部分初始化:未指定位置自动补0 int b[2][3] = { {1, 2}, {3} }; // b的内存结果:{{1, 2, 0}, {3, 0, 0}} // 连续初始化:按内存顺序依次填充 int c[2][3] = {1, 2, 3, 4, 5, 6}; // c等价于 {{1, 2, 3}, {4, 5, 6}}值得一提的是,如果第一维大小省略,编译器可以根据初始化列表自动推算:
int d[][3] = {1, 2, 3, 4, 5, 6}; // 自动推断 d 为 2行3列但第二维大小不能省略,因为编译器必须知道每行有几个元素,才能算出行首地址的偏移量。
5.4 函数传参时最容易翻车的地方
今天我专门试验了一个会报错的写法:
void print_matrix(int arr[][], int row) { // 编译错误:第二维大小未知 }编译器报错信息大概意思是“数组类型不完整”。原因就是上面说的:arr[i][j]在底层需要知道每行的列数,才能定位到arr[i]的起始位置。
正确写法有两种:
// 写法1:明确写出列数 void print_matrix(int arr[][4], int row) { for (int i = 0; i < row; i++) { for (int j = 0; j < 4; j++) { printf("%d ", arr[i][j]); } printf("\n"); } } // 写法2:用数组指针 void print_matrix(int (*arr)[4], int row) { // 和上面的行为完全一致 }前者更直观,后者在涉及指针运算时更灵活。两种写法在语义上等价,实际项目里我更常用第二种,因为后面学结构体数组和复杂指针时会延续这套思维方式。
5.5 嵌入式里二维数组的真实案例:LED点阵屏
今天练完二维数组后,我在开发板上试着驱动一个8x8的LED点阵。LED点阵屏的本质就是一个二维的像素矩阵,每个点对应一个坐标(x, y),用1表示亮,0表示灭。
// 一张 8x8 的"笑脸"图案 unsigned char pattern[8][8] = { {0, 0, 1, 1, 1, 1, 0, 0}, {0, 1, 0, 0, 0, 0, 1, 0}, {1, 0, 1, 0, 0, 1, 0, 1}, {1, 0, 0, 0, 0, 0, 0, 1}, {1, 0, 0, 0, 0, 0, 0, 1}, {1, 0, 1, 0, 0, 1, 0, 1}, {0, 1, 0, 0, 0, 0, 1, 0}, {0, 0, 1, 1, 1, 1, 0, 0} };写点阵驱动时,二维数组的坐标访问非常自然:pattern[row][col]直接对应LED的位置。如果想显示“左移”或“上移”效果,本质上是数组行列的遍历顺序变化。
按键矩阵扫描也是同一个套路,4x4键盘就可以用一个4x4的二维数组来记录键值状态。所以二维数组不是理论概念,而是嵌入式里很日常的代码组织方式。
6. 一维字符数组:\0、sizeof、strlen三者的纠缠
6.1 字符数组和字符串的区别
第五个练习主题是一维字符数组,说实话,这是今天踩坑最多的部分。
先分清楚两个概念:字符数组是“存放char类型元素的数组”,字符串则是“以\0结尾的字符序列”。
在C语言里,字符串本质上就是一个字符数组,但有一个硬性要求:必须以\0结尾。
我在今天的代码里写了这样三行对比:
char str1[] = "hello"; // 数组长度为6,最后自动补'\0' char str2[] = {'h','e','l','l','o'}; // 数组长度为5,没有'\0' char str3[5] = "hello"; // 危险写法!第一行str1中,sizeof(str1)的结果是6,而不是5。因为字符串字面量"hello"在内存里实际占据6个字节:h e l l o \0。
第二行str2是一个纯字符数组,没有\0结尾。如果对它调用strlen(str2),函数会从数组第一个元素开始数,一直往后找\0,直到在内存某个未知位置碰到一个0字节才停下。结果是未定义行为,可能返回一个随机值,也可能程序直接崩溃。
第三行更危险:str3只有5个字节,但要把"hello"的6个字节(含\0)塞进去,\0会越界写入相邻内存。虽然编译器不一定会报错,但运行阶段可能破坏其他变量的值。
6.2 我实测时踩的坑:char s[5] = "hello"
今天实际跑代码时,我写了一个类似的测试:
#include <stdio.h> #include <string.h> int main(void) { char s[5] = "hello"; int x = 100; printf("strlen(s) = %lu\n", strlen(s)); printf("x = %d\n", x); return 0; }编译时GCC只给了一个警告,没有报错。运行后strlen(s)返回一个很大的值,x的数值也变得完全不可控。原因就是\0越界写入了旁边的内存空间,破坏了x的存储。
这是我今天印象最深的一个坑。在嵌入式开发里,这种缓冲区越界问题比PC端更隐蔽,因为单片机没有操作系统保护,越界写入可能直接覆盖另一个全局变量,甚至践踏函数返回地址,导致程序跑飞或HardFault。
正确做法是:存储一个长度为n的字符串,数组至少要留n+1个字节。
6.3 sizeof 和 strlen 的区别
下面这张表整理的是我今天反复确认过的基础知识点:
| 对比项 | sizeof | strlen |
|---|---|---|
| 是运算符还是函数 | 运算符 | C标准库函数 |
| 在什么阶段计算 | 编译期计算 | 运行期计算 |
| 统计内容 | 占用内存字节数 | 到\0之前的字符个数 |
是否包括\0 | 包括 | 不包括 |
| 作用于指针时 | 得到指针大小(如4或8字节) | 仍按\0位置计算 |
举个例子:
char buf[64] = "hello"; sizeof(buf) // 结果为64,是数组的总容量 strlen(buf) // 结果为5,是有效内容长度很多嵌入式串口通信场景正是利用这两个值的差异:strlen拿到实际收到的字符数,sizeof拿到缓冲区能容纳的最大量,写环形缓冲区或溢出判断时都会用到。
6.4 串口协议里的字符数组:一个真实练习
今天最后一个练习,我模拟了串口接收AT命令并解析的过程。大概思路是:串口中断把字符逐字节存入rx_buf,收到\r\n后封包,再在状态机里解析。
#include <stdio.h> #include <string.h> #define RX_BUF_SIZE 64 int main(void) { // 模拟串口收到的一帧数据 char rx_buf[RX_BUF_SIZE] = {0}; const char *recv = "AT+LED=ON\r\n"; // 去掉末尾的换行回车,保证字符串以'\0'正确结束 strncpy(rx_buf, recv, RX_BUF_SIZE - 1); rx_buf[strcspn(rx_buf, "\r\n")] = '\0'; // 解析指令 if (strcmp(rx_buf, "AT+LED=ON") == 0) { printf("LED ON\n"); } else if (strcmp(rx_buf, "AT+LED=OFF") == 0) { printf("LED OFF\n"); } else { printf("Unknown command: %s\n", rx_buf); } printf("strlen(rx_buf) = %lu\n", strlen(rx_buf)); printf("sizeof(rx_buf) = %lu\n", sizeof(rx_buf)); return 0; }这个例子里有几个细节值得记下来:
一是RX_BUF_SIZE必须比实际数据长度多留空间,给\0留位置;
二是用strncpy而不是strcpy,可以避免源字符串过长时溢出目标缓冲区;
三是注意strcmp的返回值——为0表示相等。新手容易写成if (!strcmp(...))或if (strcmp(...) == 0),建议统一用后者,语义更清晰。
字符数组在嵌入式里最常见的角色就是“通信协议的消息载体”。串口、SPI、I2C、CAN收发数据时,看到的都是字符数组的身影,所以这块基础真的不能含糊。
最后记一个今天的小体会:写排序和数组相关代码时,脑子里一定要有“数据移动”的画面。冒泡是相邻元素逐步交换,选择是挑出最小值直接归位,倒置是首尾指针向中间逼近。把这些画面印在脑子里,再去看代码或手写代码,会比死记硬背流畅得多。明天开始进入指针部分,到时候再回头看看今天二维数组和字符数组的代码,应该会有完全不同的理解。