
做嵌入式机器人开发这几年我越来越清楚一件事具身智能落地时真正卡脖子的往往不是那些炫酷的神经网络推理框架而是底层用来存数据、管内存的这几块“地基”。机械臂关节角度采集、激光雷达点云缓冲、传感器数据流缓存——这些场景里C语言写动态数组几乎是每个搞具身智能的人迟早要亲手过一遍的关卡。这个系列我打算从动态数组开始。不是说它高级恰恰相反正是因为几乎所有上层逻辑都建立在“不确定长度的数据怎么存”这个问题之上动态数组才成了第一个值得认真抠细节的数据结构。这篇先把它讲透结构设计、扩容策略、内存安全、常见的坑最后用机械臂关节数据的例子串起来保证你写完就能用在自己的项目里。1. 内容整体设计与思路拆解为什么具身智能要先啃动态数组1.1 具身智能场景里C语言为什么绕不开动态数组先说说具身智能和普通互联网后台开发的区别。互联网后端你随手vector.push_back()完事跑在x86服务器上内存按GB算性能差点无所谓。但具身智能不一样——机械臂的控制板无人机飞控足式机器人的主控芯片很多跑的还是ARM Cortex-M甚至更小的MCU内存按KB到MB算实时性要求到毫秒级。这种情况下C语言几乎是唯一稳的选择因为它给不了你现成的ArrayList你得自己写。我举个具体例子。给机械臂写关节角度采集程序时你并不知道一次完整轨迹里会收到多少个插补点。可能是几百个也可能是几千个取决于运动指令的复杂度和周期。你要么预先开一块固定大小的数组赌它够用要么就得想一个办法让数组能“讲故事”装满了就自动变大删掉元素后还能自动缩小。固定数组的问题在于你估小了会越界估大了就是浪费。Dobot机械臂单关节角度用double存就是8字节如果写一个跟踪系统一次性缓存未来3秒的所有插补点假设1kHz的刷新率那就是3000个点你要是开个4096的数组内存勉强够。但如果哪天刷新率提到4kHz、缓存时间拉到5秒就是20000个点固定数组就得爆。这种场景下动态数组的“自动长大”能力就是刚需。1.2 动态数组到底解决的是什么问题动态数组相较于静态数组解决的核心问题说白了就一个在不知道最终长度的情况下还能用连续内存来存储元素。它有两个关键约束地址连续——这是为了缓存友好。在嵌入式里CPU缓存的预取机制对连续内存有天然优势。机械臂控制循环里频繁遍历大量传感器数据时连续内存的遍历速度能比链表快出一个量级。容量动态变化——这是为了灵活性。数据量小时省内存数据量大时不爆栈。这种“既要连续、又要可变”的要求决定了实现方式不是魔法而是两件普通事拼在一起一块用malloc申请的堆内存加上一个记录容量和大小的结构体。满了就重新分配一块更大的内存把旧元素搬过去再释放旧的。很多科班出身但你问他“动态数组扩容为什么要乘以2而不是加固定值”他说不出个所以然。这里先留个钩子后面第2节我会详细展开这个“为什么”。1.3 为什么这个系列从动态数组开始而不是链表、栈说实话一开始我也纠结过要不要先写链表因为教科书都喜欢先讲链表。但后来做机器人项目发现动态数组在很多具身智能场景里是默认选项关节角度序列、IMU读数序列、点云排序结果——基本都是“连续采集、顺序处理”不需要频繁中间插入。链表的节点分散在内存各处遍历时缓存命中率低对实时性要求高的控制回路不友好。链表每个节点要额外存一个指针在内存按KB算的单片机上这开销不容忽视。所以这系列第一篇写动态数组不只是因为它“简单”而是因为它在工程中是出镜率最高、最适合做地基的那一个。你把这个结构体写稳了后面写栈、写队列、写哈希表时会发现都是“查字典”的问题。2. 核心细节解析与实操要点动态数组的结构设计与内存策略2.1 结构体怎么设计三个字段缺一不可动态数组的实现思路很清晰但要设计得稳三个字段一个都不能少typedef struct { void **data; // 指向存储空间的指针 size_t size; // 当前元素个数 size_t capacity; // 当前容量能容纳的最大元素个数 } DynamicArray;这里用void **而不是int *是为了让一个结构体能复用于各种类型。你可以存double类型的关节角度也可以存自定义的结构体指针比如一个点云点的链表。具身智能项目里数据类型特别杂角度、速度、坐标、图像特征、位姿矩阵……不可能给每种类型写一套动态数组。用void *泛化是最经济的做法。size和capacity这两个字段容易搞混我面试时经常问这个问题。size是“已用的”capacity是“可用的”。当初学的时候有个很好的生活类比一个行李箱capacity是箱子能装多少件衣服size是你现在实际塞了多少件。箱子没装满可以直接往里放装满了就得换个大箱子把旧衣服都挪过去再继续放——这就对应扩容。2.2 扩容策略为什么用“倍增”而不是“加固定长度”这是动态数组里最有技术含量的问题。很多人直接抄代码写new_cap cap * 2却不知道为什么。先算一笔账。假设数组从容量1开始每次扩容增加1个位置那么插入第 n 个元素时需要扩容 n-1 次累计拷贝次数是 12...(n-1)≈n²/2均摊到每次插入复杂度是 O(n)。也就是说轻轻松松就能让插入从O(1)退化到线性时间。但如果采用“倍增”策略——new_cap cap * 2那么从1增长到n只需要log₂(n)次扩容累计拷贝次数是124...n≈2n均摊到每次插入复杂度是O(1)。两种策略在数据量大时的差别就是“几十毫秒”和“几十微秒”的差别对于1kHz控制周期来说这是灾难和正常的区别。实际操作里扩容因子选2还是1.5也有讲究。选2的坏处是内存浪费可能比较多容量1024你实际用了513个就有511个空位。选1.5能稍微缓解浪费代价是扩容次数变多。我在机器人项目里一般选2因为机器人控制数据通常是高频小数据减少扩容次数的收益比内存浪费的代价更明显。2.3 缩容不是必须但偷懒也要偷出水平很多动态数组实现只做扩容不做缩容。在PC上问题不大但在嵌入式设备上这可能会让你把内存越吃越少最后系统重启。正确的缩容思路是删除元素后当 size 远小于 capacity 时才缩容。比如size capacity / 4时把容量缩一半。注意两个问题缩容不能太激进否则会“抖动”一会儿删多了缩容一会儿又插满了扩容反复搬运数据性能被白白吃掉。阈值一般选capacity / 4留一半缓冲这是常见的实践。缩容用realloc就行。但realloc有个特性要注意它可能会直接把内存块挪个位置并复制数据也可能原地扩容。你之前拿到的那个旧指针在realloc之后就不一定有效了必须用返回值更新。2.4 插入和删除memmove和memcpy有本质区别数组的中间插入和删除不可避免要搬移元素复杂度是O(n)。关键是怎么搬很多新手在这个地方翻车。搬移内存有两个函数memcpy和memmove。最早的教科书代码喜欢用memcpy但memcpy不允许源和目的地址重叠。数组中间插入时源区域和目标区域天然是重叠的——你要把从idx开始的元素整体往后挪一格这时目标地址和源地址高度重叠。memcpy在这种场景下是未定义行为可能因为拷贝方向的问题把数据搞坏。正确做法是用memmove它专门处理重叠问题内部会判断拷贝方向保证结果正确。这个坑我实测过用memcpy在x86上可能碰巧没问题但在ARM上就经常出现“最后一个元素重复了两次”之类的诡异现象。所以插入删除、扩容缩容涉及搬移时一律用memmove。2.5 传引用还是传值封装粒度决定你的调试体验写动态数组时还有一个设计决策函数签名是传结构体指针还是传结构体本身。我强烈建议所有操作函数都传指针比如void da_init(DynamicArray *arr); void da_push_back(DynamicArray *arr, void *elem); int da_insert(DynamicArray *arr, size_t idx, void *elem);这样可以实现“不透明指针”的思想。在头文件里只暴露DynamicArray*定义藏在.c文件里。外部代码没法随便访问内部字段只能通过API操作这能拦住很多手痒的人乱改size和capacity。机器人代码通常要跑几百天不重启这种防御性编程的收益会随时间放大。3. 实操过程与核心环节实现一个可复用的C动态数组3.1 完整实现头文件与核心API先给出一个简洁但完整的版本。我把接口按用途分成三组初始化与销毁、增删、查询访问。// dynarr.h #ifndef DYNARR_H #define DYNARR_H #include stddef.h typedef struct DynamicArray DynamicArray; /* 创建与销毁 */ DynamicArray *da_create(size_t init_cap); void da_destroy(DynamicArray *arr); /* 增删改 */ int da_push_back(DynamicArray *arr, void *elem); int da_insert(DynamicArray *arr, size_t idx, void *elem); int da_erase(DynamicArray *arr, size_t idx); int da_pop_back(DynamicArray *arr); /* 查询访问 */ void *da_get(DynamicArray *arr, size_t idx); int da_set(DynamicArray *arr, size_t idx, void *elem); size_t da_size(DynamicArray *arr); size_t da_capacity(DynamicArray *arr); #endif// dynarr.c #include dynarr.h #include stdlib.h #include string.h struct DynamicArray { void **data; size_t size; size_t capacity; }; static int da_resize(DynamicArray *arr, size_t new_cap) { void **new_data realloc(arr-data, new_cap * sizeof(void *)); if (!new_data) return -1; arr-data new_data; arr-capacity new_cap; return 0; } DynamicArray *da_create(size_t init_cap) { DynamicArray *arr malloc(sizeof(DynamicArray)); if (!arr) return NULL; arr-size 0; arr-capacity init_cap ? init_cap : 1; arr-data malloc(arr-capacity * sizeof(void *)); if (!arr-data) { free(arr); return NULL; } return arr; } void da_destroy(DynamicArray *arr) { if (!arr) return; free(arr-data); free(arr); } int da_push_back(DynamicArray *arr, void *elem) { if (arr-size arr-capacity) { size_t new_cap arr-capacity * 2; if (da_resize(arr, new_cap) ! 0) return -1; } arr-data[arr-size] elem; return 0; } int da_insert(DynamicArray *arr, size_t idx, void *elem) { if (idx arr-size) return -1; if (arr-size arr-capacity) { size_t new_cap arr-capacity * 2; if (da_resize(arr, new_cap) ! 0) return -1; } memmove(arr-data idx 1, arr-data idx, (arr-size - idx) * sizeof(void *)); arr-data[idx] elem; arr-size; return 0; } int da_erase(DynamicArray *arr, size_t idx) { if (idx arr-size) return -1; memmove(arr-data idx, arr-data idx 1, (arr-size - idx - 1) * sizeof(void *)); arr-size--; if (arr-capacity 4 arr-size arr-capacity / 4) { da_resize(arr, arr-capacity / 2); } return 0; } int da_pop_back(DynamicArray *arr) { if (arr-size 0) return -1; arr-size--; if (arr-capacity 4 arr-size arr-capacity / 4) { da_resize(arr, arr-capacity / 2); } return 0; } void *da_get(DynamicArray *arr, size_t idx) { if (idx arr-size) return NULL; return arr-data[idx]; } int da_set(DynamicArray *arr, size_t idx, void *elem) { if (idx arr-size) return -1; arr-data[idx] elem; return 0; } size_t da_size(DynamicArray *arr) { return arr-size; } size_t da_capacity(DynamicArray *arr) { return arr-capacity; }这套实现的边界情况处理得相对周全初始容量为0时会自动设为1避免第一次插入时capacity * 2得到0导致分配失败缩容留了4倍缓冲防止容量抖动所有索引越界的函数都返回错误码而不是直接崩溃。3.2 具身智能场景实战模拟机械臂关节数据采集光把API写完还不够得看实际怎么用。我用一个经典场景来走一遍流程机械臂6个关节的高频角度采集。假设你有一个轨迹插补器每毫秒生成6个关节的目标角度。你要把这些数据按时间顺序存起来供上层控制算法回溯分析和性能评估。如果用固定数组你得预先猜轨迹长度猜少了数据丢失猜多了内存浪费。用动态数组逻辑就清晰了#include stdio.h #include stdlib.h #include dynarr.h typedef struct { double joints[6]; // 六个关节角度单位弧度 unsigned long tick; // 时间戳 } JointFrame; int main(void) { DynamicArray *frames da_create(1024); if (!frames) return 1; // 模拟采集100000帧数据 for (int i 0; i 100000; i) { JointFrame *frame malloc(sizeof(JointFrame)); if (!frame) { fprintf(stderr, 内存不足第%d帧丢弃\n, i); break; } frame-tick i; for (int j 0; j 6; j) { frame-joints[j] 0.5 * i j; // 模拟角度变化 } if (da_push_back(frames, frame) ! 0) { fprintf(stderr, 第%d帧入队失败\n, i); free(frame); break; } } // 回溯最后10帧验证数据完整性 size_t n da_size(frames); for (size_t idx n 10 ? n - 10 : 0; idx n; idx) { JointFrame *f da_get(frames, idx); if (f) { printf(tick%lu, joint0%.2f\n, f-tick, f-joints[0]); } } // 清场先释放每帧数据再销毁数组 for (size_t idx 0; idx da_size(frames); idx) { free(da_get(frames, idx)); } da_destroy(frames); return 0; }这里有个关于内存管理的点要特别说清楚da_push_back只存指针不负责管理指针指向的“数据本身”的生死。所以销毁数组之前你得先“挨个释放数据”再销毁数组。千万别顺序搞反否则就会内存泄漏。反过来如果你在da_destroy里顺手把里面的指针都释放了那当你存的是指向栈上变量的指针时程序就会崩溃。动态数组的API不应该管数据的生命周期只管指针的存储这是我踩过坑之后坚持的原则。3.3 扩容触发时机与realloc的隐藏行为再看一下da_push_back里的扩容逻辑。判断条件是size capacity也就是数组“真的满了”才扩容。这里有一个数值上的边界如果当前capacity已经是很大的数比如接近SIZE_MAX / 2那capacity * 2会溢出变成0或负数导致realloc行为怪异甚至崩溃。严谨的做法是加一个溢出检查if (arr-size arr-capacity) { if (arr-capacity SIZE_MAX / 2 / sizeof(void *) / 2) { return -1; // 防溢出不再扩容 } size_t new_cap arr-capacity * 2; if (da_resize(arr, new_cap) ! 0) return -1; }虽然在普通机器人项目里很难遇到这种天文数字但养成习惯在写通用库时加上防御不亏。这也是一个面试官爱问的考点“动态数组扩容前要注意什么”除了溢出检查还要注意realloc失败时旧指针仍然有效仍然需要你free。但realloc失败后旧内存块不会自动释放所以如果直接arr-data realloc(...)一旦 realloc 返回 NULL旧数据就泄露了原来的指针也找不到了。我见过最坑的一种写法是这样// 错误示范 arr-data realloc(arr-data, new_cap * sizeof(void *));如果realloc返回 NULL那arr-data就被赋成了 NULL旧的内存块无法访问直接泄漏。所以正确的写法是先存到临时变量检查成功后再更新原指针。上面da_resize就是这么写的。3.4 测试与调试用valgrind和ASan验证内存安全写完代码不是结束还得反复验。具身智能代码要长时间稳定运行内存问题如果不在开发期暴露到了现场就会出现“跑几天后随机死机”这种极难排查的故障。我常用的验证工具两个valgrindLinux下经典内存检测工具检测内存泄漏、越界读写。跑一遍示例程序确认“definitely lost: 0 bytes”。AddressSanitizer编译时加 sanitizer更快能较精确指出是哪一行越了界。我用这套代码在Linux下编译测试的命令gcc -g -O0 -fsanitizeaddress dynarr.c example.c -o example ./example如果da_get越界之类的问题ASan会直接定位到第几行非常爽。建议在项目早期就把这套检查跑成常规流程别等出了问题再补。4. 常见问题与排查技巧实录动态数组的坑我帮你踩过了4.1 悬垂指针元素被缩容搬运后旧指针不能再用这是动态数组最容易踩的“隐形坑”。da_resize调用realloc后数据可能被搬到新地址。如果你之前拿了一个指向数组中某个元素的原始指针比如void **p arr-data[5]在扩容之后这个指针就悬垂了指向的地址可能已经被释放。更隐蔽的是数组里存的都是void *指针指向的是你 malloc 出来的数据对象数据对象本身不会被realloc搬走——搬走的只是存放这些指针的数组空间。所以悬垂的是“数组的槽位地址”不是“槽位指向的那个数据对象”。这个区别很重要很多人混淆了导致排查时走了弯路。避坑守则数组经过扩容后之前获取的“下标地址”或“data指针”一律作废。要访问元素重新da_get(arr, idx)别自己缓存arr-data idx。4.2 浅拷贝陷阱存了同一块内存的多个指针动态数组存的是指针副本。这意味着你把一个栈上JointFrame frame的地址传给da_push_back然后frame出作用域后被销毁数组里的指针就指向了垃圾数据。这其实是C语言编程的基本功但配合动态数组时容易被忽略因为数组看起来像个“容器”容易误以为它替你“保存了值”。实操里我见过有人这样写for (int i 0; i 10; i) { JointFrame frame {0}; frame.tick i; da_push_back(arr, frame); // 错每轮循环的 frame 都是同一个栈地址 }循环结束后数组里10个元素全指向最后一个循环的栈变量。你看到的实际是同一个地址内容全变成最后一帧的数据。解决方法是每个入队的元素都malloc一份独立内存放进数组。这也正好跟之前说的“只管理指针不管数据生命周期”呼应上。4.3 memmove比memcpy稳的那个坑再强调一次前面已经说了区别但这里我给出一个实测翻车记录。我早期在STM32上写动态数组插入函数里用了memcpy。测试单次插入没问题连续插入几十个数据后发现数组里出现了“元素重复”的乱象比如[1,2,3,4]在1号位插入9变成了[1,9,2,2,4]。排查了半天定位到就是memcpy源目的重叠导致数据被覆盖。换成memmove后一切正常。ARM上的这个现象比x86明显得多。所以请把这个教训焊死在脑子里搬移重叠内存永远用memmove。4.4 容量和大小混淆越界读的元凶size和capacity混淆是动态数组新手高频错点。da_get(arr, i)内部检查的是i size不是i capacity。因为 capacity 只是“当前最多能装多少”不代表里面都有意义的数据。你申请了能装100个的内存空间但只放了10个元素你访问arr-data[50]虽然没越界但它是个未初始化的垃圾指针解引用必炸。我曾经在一个路径规划项目里用动态数组存路径点结果遍历时写成了for (i0; icapacity; i)导致把空槽里的垃圾数据也当成了路径点路径直接扭曲。排查了半天罪魁祸首就是遍历条件写错。所以遍历永远用size。4.5 异常分支的资源清理别在失败路径里漏了free写了da_insert发现扩容失败返回 -1那之前已经malloc过的元素怎么办这就涉及一个经验法则谁负责分配谁负责释放。调用者传入元素指针数组只负责存储指针如果插入失败调用者自己决定是否free这个元素。示例里我写的就是插入失败时free(frame)后break。这种细节在长期运行的程序里会以“内存泄漏缓慢增长”的形式暴露出来不查则已一查吓一跳。可以做一个简单的自查清单每次写完都扫一遍所有 malloc/calloc 都有对应的 free 路径吗包括失败分支销毁动态数组前按顺序释放了每个元素吗会不会有人在数组销毁后还访问数组里面的指针4.6 嵌入式环境下的可用替代CMSIS动态数组与内存池最后补充一个经验如果用的是RTOS或者CMSIS环境有时不一定要自己完全从零实现动态数组。CMSIS-DSP库里其实有arm_fill之类的工具函数但数据结构本身还是得自己搭。另一个思路是对实时性要求极高的控制回路可以预分配一块固定大小的内存池然后用动态数组的逻辑来管理这块池内的“逻辑容量”。这样既能享受动态数组的语义又能避免malloc在实时环境中带来的不确定性malloc可能导致堆碎片延长分配时间这对毫秒级控制回路是致命的。实际做法是固定数组元素个数上限手动维护size数组满了就覆盖最旧的或者直接报错不做动态扩容。这种折中方案在机器人控制中很常见。所以动态数组不是唯一解但理解它的实现原理能帮你判断什么时候该用真动态数组、什么时候用固定环形缓冲区就够了。写在最后关于这个系列的一点个人体会动态数组看起来是数据结构里最基础的一课但翻车点比很多人想象的要多。我早期在机器人项目里写动态数组自以为三分钟搞定结果内存泄漏、悬垂指针、memcpy重叠问题挨个踩了一遍最后是靠valgrind一个一个揪出来的。现在总结下来核心就几点存储和生命周期分离、扩容倍增但要防溢出、搬移用memmove、遍历永远看size、每次分配都要有对应的释放。把这几点刻进肌肉记忆你后面写栈、写队列、写哈希表都会顺很多。下一篇文章我准备接着这个节奏讲怎么基于这套动态数组实现一个线程安全的环形队列专门用来处理激光雷达点云数据的实时缓冲。那个场景里下标管理、多线程锁、内存复用的问题比今天这个更过瘾。咱们下篇见。