
Day9的练习做完已经是晚上九点半了。今天的内容看着都是老熟人——倒置、冒泡、选择、二维数组、一维字符数组但真在嵌入式板子上跑起来还是踩了不少坑。比如二维数组传参时忘记写列数编译器直接报错比如字符数组初始化时少留了一个字节串口命令解析结果奇奇怪怪再比如最基础的倒置我一开始没加临时变量数据直接被覆盖。这篇日志把今天所有练习代码和避坑过程完整记录下来给同样在走嵌入式学习路线的朋友做个参考。如果你是零基础刚学到数组或者准备嵌入式面试想复习排序和数组这篇应该能帮你少走两三个小时的弯路。1. 为什么第9天的内容几乎全在“折腾内存”1.1 嵌入式场景里数组到底用在哪很多人在PC上写代码觉得数组就是个“连续存放的一堆数”用不用无所谓。但到了嵌入式环境里数组几乎是无处不在的基础设施。嵌入式设备和普通程序最大的区别是资源受限MCU的RAM经常只有几KB到几十KBFlash也就几十KB到几百KB。你不能像写桌面软件那样随便开几个大的动态数据结构更不能指望垃圾回收机制帮你管理内存。于是固定大小的数组就成了嵌入式代码里最常用的数据容器。今天的练习里我反复在做的几件事背后都是很典型的嵌入式场景传感器数据采集ADC连续采样100次放进数组之后做滤波或取平均通信缓冲区串口收到一帧数据先存进字符数组再解析协议状态管理按键矩阵、LED点阵屏本质上都是二维数组在描述坐标状态命令表用字符数组保存AT指令或自定义协议指令再和收到的数据比对。数组在嵌入式里不是“练习题”而是每天都要写的东西。1.2 排序在单片机工程中的现实意义那排序呢很多人第一反应是单片机里基本用不到排序吧功能简单数据量也小犯不着写个冒泡或者选择。这个说法有对的部分也有不对的部分。对的地方是工业级嵌入式代码里确实很少会专门写一个排序算法。不对的地方是排序思想在数据处理里随处可见面试还爱考而且有些场合你必须自己排序。举个例子今天我在板子上做了一个温度采集练习。DS18B20或者内部ADC采回来的原始数据会有随机抖动和偶尔的毛刺。常见的处理方式之一是“去掉最大最小值再求平均”也就是把采集数组排序后掐头去尾再对中间数据求平均。这就是个非常典型的排序应用场景不需要多复杂的算法冒泡就够用了。再比如嵌入式设备记录日志时常需要按时间戳或者优先级把记录排一下做用户界面的时候菜单列表可能要按某种规则展示。这些都用得上排序。当然更现实的原因是嵌入式岗位面试时手写冒泡或选择排序几乎是保留节目。从热搜词里也能看出来“嵌入式面试八股文”“嵌入式面试题”里排序算法一直是高频内容。所以不管工作用不用这关必须过。1.3 今天的练习会为后面哪些内容铺路其实Day9最大的价值不在排序本身而在于为指针和链表打基础。数组倒置练习里传参用的是int arr[]本质上就是一个指向数组首元素的指针。二维数组的a[i][j]访问方式背后是*(*(ai)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]的原始值已经被覆盖了。这也是新手最容易踩的坑之一。正确做法是引入临时变量tmpint 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的字符串数组至少要留n1个字节。6.3 sizeof 和 strlen 的区别下面这张表整理的是我今天反复确认过的基础知识点对比项sizeofstrlen是运算符还是函数运算符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 ATLEDON\r\n; // 去掉末尾的换行回车保证字符串以\0正确结束 strncpy(rx_buf, recv, RX_BUF_SIZE - 1); rx_buf[strcspn(rx_buf, \r\n)] \0; // 解析指令 if (strcmp(rx_buf, ATLEDON) 0) { printf(LED ON\n); } else if (strcmp(rx_buf, ATLEDOFF) 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收发数据时看到的都是字符数组的身影所以这块基础真的不能含糊。最后记一个今天的小体会写排序和数组相关代码时脑子里一定要有“数据移动”的画面。冒泡是相邻元素逐步交换选择是挑出最小值直接归位倒置是首尾指针向中间逼近。把这些画面印在脑子里再去看代码或手写代码会比死记硬背流畅得多。明天开始进入指针部分到时候再回头看看今天二维数组和字符数组的代码应该会有完全不同的理解。