C语言的一些“骚操作”及其深层理解

新闻资讯   2023-06-30 12:01   63   0  
此系列文章,于振南老师向大家讲述嵌入式C语言的一些高阶知识,俗称“骚操作”,助你水平再上一个台阶!
C语言,是一门非常灵活而强大的编程语言。同样一个算法、一个功能,我们可以把它写得中规中矩,也可以把它写得晦涩难懂。而且很多自诩为编程高手的人,偏偏就喜欢把程序写成天书,认为让别人看不懂,却能实现正确的功能,此乃技术高超的表现。
我不评价这样的作法是否可取,因为每个人都有各自的风格和个性。让他违背意愿去编程,那么编程可能就会变得索然无味,毫无乐趣。很多时候不是我们想把程序写得难懂,而是我们要去看懂别人的程序。
在本文中,振南列举一些我曾经见过和使用过的编程技巧,并进行深入的解析。

一、字符串的实质就是指针

字符串是C语言中最基础的概念,也是最常被用到的。在嵌入式开发中,我们经常要将一些字符串通过串口显示到串口助手或调试终端上,作为信息提示,以便让我们了解程序的运行情况;或者是将一些常量的值转为字符串,来显示到液晶等显示设备上。
那么,C语言中的字符串到底是什么?其实字符串本身就是一个指针,它的值(即指针所指向的地址)就是字符串首字符的地址。
为了解释这个问题,我经常会举这样一个例子:如何将一个数值转化为相应的16进制字符串。比如,把100转为”0X64”。
我们可以写这样一个函数:
void Value2String(unsigned char value,char *str)
{

char *Hex_Char_Table="0123456789ABCDEF";

str[0] = '0';
str[1] = 'X';
str[4] = 0;

str[2]=Hex_Char_Table[value>>4];

str[3]=Hex_Char_Table[value&0X0F];
}

字符串常量实质是内存中的字节序列,如下图所示:

上面,振南说“字符串本身就是指针”,那么见证这句话真正意义的时刻来了,我们将上面程序进行简化:
void Value2String(unsigned char value,char *str)
{

str[0]='0';str[1]='X';str[4]=0;

str[2]="0123456789ABCDEF"[value>>4];

str[3]="0123456789ABCDEF"[value&0X0F];
}

Hex_Char_Table 这个指针变量其实是多余的,“字符串本身就是指针”,所以它后面可以直接用 [] 配合下标来取出其中的字符。凡是实质上为指针类型(即表达的是地址意义)的变量或常量,都可以直接用[]或*来访问它所指向的数据序列中的数据元素。

二、转义符 \

C语言中要表达一个字节数据序列(内存中连续存储的若干个字节),我们可以使用字节数组,比如unsigned char array[10]={0,1,2,3,4,5,6,7,8,9}。
其实字符串,本质上也是一个字节序列,但是通常情况下它所存储的字节的值均为 ASCII 中可打印字符的码值,如’A’、’ ‘、’|’等。那在字符串中是否也可以出现其它的值呢?这样,我们就可以用字符串的形式来表达一个字节序列了。很多时候,它可能比字节数组要方便一些。字符串中的转义符就是用来干这个的。请看如下程序:
const unsigned char array[10]={0,1,2,3,4,5,6,7,8,9};

char *array="\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09";
这两种写法,array所指向的内存字节序列是基本一样的(后者最后还有一个0)。当然,如果我们把array传到strlen去计算长度,返回的值为0。因为它第一个字节的值为0。但是我们仍然可以使用array[n]的方式去访问序列中的数据。
char *str="ABCDEFG";

char *str="\x41\x42\x43\x44\x45\x46\x47";

上面程序中的两种写法,是完成等价的。

字符串中的转义符的目的是为了在本应该只能看到ASCII可打印字符的序列中,可以表达其它数值或特殊字符。如经常使用的回车换行”\r\n”,其实质就是”\x0d\x0a”;通常我们所说的字符串结束符\0,其实就是0的八进制转义表达形式。

三、字符串常量的连接

在研读一些开源软件的源代码时,我见到了字符串常量的一个比较另类的用法,在这里介绍给大家。
有些时候,为了让字符串常量内容层次更加清晰,就可以把一个长字符串打散成若干个短字符串,它们顺序首尾相接,在意义上与长字符串是等价的。比如“0123456789ABCDEF”可以分解为“0123456789”“ABCDEF”,即多个字符串常量可以直接连接,够成长字符串。这种写法,在 printf 打印调试信息的时候可能会更多用到。
printf("A:%d B:%d C:%d D:%d E:%d F:%d\r\n",1,2,3,4,5,6);

printf("A:%d " \
  "B:%d " \
  "C:%d " \
  "D:%d " \
  "E:%d " \
  "F:%d\r\n",1,2,3,4,5,6);
在 printf 的格式化串很长的时候,我们把它合理的打散,分为多行,程序就会显得更多工整。

四、长字符串的拆分技巧

很多时候我们需要进行长字符串的拆分。问题是如何实现它?
很多人可能都会想到使用那个分隔字符,比如空格、逗号。然后去一个个数要提取的参数前面有几个分隔字符,然后后将相应位置上的字符组成一个新的短字符串。如下图所示:
这种方法固然可行,但是略显笨拙。其实,对于这种有明显分隔符的长字符串,我们可以采用“打散”或“爆炸”的思想,具体过程是这样的:将长字符串中的所有分隔符全部替换为’\0’,即字符串结束符。此时,长字符串就被分解成了在内存中顺序存放的若干个短字符串。
如果要取出第n个短字符串,可以用这个函数:
char * substr(char *str,n)
{
unsigned char len=strlen(str);

for(;len>0;len--) {if(str[len-1]==' ') str[len-1]=0;}

for(;n>0;n--)
{
str+=(strlen(str)+1);
}

return str;
}
很多时候我们需要一次性访问长字符串中的多个短字符串,此时振南经常会这样来作:通过一个循环,将长字符串中的所有分隔符替换为’\0’,在此过程中将每一个短字符串首字符的位置记录到一个数组中,代码如下:
unsigned char substr(unsigned char \*pos,char \*str)
{

unsigned char len=strlen(str);
unsigned char n=0,i=0;

for(;i<len;i++)
{
if(str[i]==' ')
{
str[i]=0;pos[n++]=(i+1);
}
}

return n;
}
举个例子:我们要提取”abc 1000 50 off 2500”中的”abc”、”50”和”off”,可以使用上面的函数来实现。
unsigned char pos[10];
char str[30];

strcpy(str,"abc 1000 50 off 2500");

substr(pos,str);
str+pos[0]; //"abc"
str+pos[2]; //"50"
str+pos[3]; //"off"

五、取出数值的各位数码

在实际项目中,我们经常需要提取一个数值的某些位的数码,比如用数码管来显示数值或将一个数值转成字符串,都会涉及到这一操作。
那如何实现这一操作呢?虽然这个问题看似很简单,但提出这一问题的人还不在少数。
请看下面的函数:
void getdigi(unsigned char *digi,unsigned int num)
{

digi[0]=(num/10000)%10;
digi[1]=(num/1000)%10;
digi[2]=(num/100)%10;
digi[3]=(num/10)%10;
digi[4]=num%10;
}
它的主要操作就是除法和取余。这个函数只是取出一个整型数各位的数码,那浮点呢?

其实一样的道理,请看下面函数(我们默认整数与小数部分均取4位)。

void getdigi(unsigned char *digi1,unsigned char *digi2,unsigned float num)
{
unsigned int temp1=num;
unsigned int temp2=((num-temp1)\*10000);

digi1[0]=(temp1/1000)%10;
digi1[1]=(temp1/100)%10;
digi1[2]=(temp1/10)%10;
digi1[3]=(temp1)%10;

digi2[0]=(temp2/1000)%10;
digi2[1]=(temp2/100)%10;
digi2[2]=(temp2/10)%10;
digi2[3]=(temp2)%10;
}
有人说,我更喜欢用sprintf函数,直接将数值格式化打印到字符串里,各位数码自然就得到了。
char digi[10];

sprintf(digi,"%d",num); //**整型

char digi[10];

sprintf(digi,"%f",num); //**浮点

没问题。但在嵌入式平台上使用sprintf函数,通常代价是较大的。

作为嵌入式工程师,一定要惜字如金,尤其是在硬件资源相对较为紧张的情况下。sprintf非常强大,我们只是一个简单的提取数值数码或将数值转为相应的字符串的操作,使用它有些暴殄天物。这种时候,我通常选择写一个小函数或者宏来自己实现。

六、printf的实质与使用技巧

printf是我们非常熟悉的一个入门级的标准库函数,每当我们说出计算机金句”Hello World!”时,其实无意中就提到了它:printf(“hello world!”);
它可以某种特定的格式、进制或形式输出任何变量、常量和字符串,为我们提供了极大的方便,甚至成为了很多人调试程序时重要的Debug手段。但是在嵌入式中,我们就需要剖析一下它的实质了。
printf 函数的底层是基于一个 fputc 的函数,它用于实现单个字符的具体输出方式,比如是将字符显示到显示器上,或是存储到某个数组中(类似sprintf),或者是通过串口发送出去,甚至不是串口,而是以太网、CAN、I2C等接口。
以下是一个STM32项目中fputc函数的实现:
int fputc(int ch, FILE \*f)
{
while((USART1->SR&0X40)==0);
{
USART1->DR = (u8) ch;
}

return ch;
}

fputc中将ch通过USART1发出。这样,我们在调用printf的时候,相应的信息就会从USART1打印出来。

“上面你说的这些,我都知道,有什么新鲜的!”确实,通过串口打印信息是我们司空见惯的。
那么,下面的fputc你见过吗?
int fputc(int ch, FILE *f)
{
LCD_DispChar(x,y,ch);
x++;
if(x>=X_MAX)
{
    x=0;y++;

    if(y>=Y_MAX)*
    {
      y=0;
    }
}
    return ch;

}

这个fputc将字符显示在了液晶上(同时维护了字符的显示位置信息),这样当我们调用printf的时候,信息会直接显示在液晶上。

说白了,fputc 就是对数据进行了定向输出。这样我们可以把 printf 变得更灵活,来应对更多样的应用需求。
在振南经历的项目中,曾经有过这样的情况:单片机有多个串口,串口1用于打印调试信息,串口2与ESP8266 WIFI模块通信,串口3与SIM800 GPRS模块通信。3个串口都需要格式化输出,但printf只有一个,这该怎么办?
我们解决方法是,修改fputc使得printf可以由3个串口分时复用,具体实现如下:
unsigned char us=0; 

int fputc(int ch,FILE *f)
{
switch(us)
{
case 0:
while((USART1->SR&0X40)==0);
USART1->DR=(u8)ch;  
break;

case 1:
while((USART2->SR&0X40)==0);
USART2->DR=(u8)ch;  
break;

case 2:
while((USART3->SR&0X40)==0);
USART3->DR=(u8)ch;  
break;
}

return ch;
}
在调用的时候,根据需要将us赋以不同的值,printf就归谁所用了。
#define U_TO_DEBUG     us=0;
#define U_TO_ESP8266   us=1;
#define U_TO_SIM800     us=2;

U_TO_DEBUG
printf("hello world!");

U_TO_ESP8266
printf("AT\r\n");


U_TO_SIM800
printf("AT\r\n");

七、关于浮点数的传输

很多人不能很好的使用和处理浮点,其主要根源在于对它的表达与存储方式不是很理解。最典型的例子就是经常有人问我:“如何使用串口来发送一个浮点数?”
我们知道C语言中有很多数据类型,其中unsigned char、unsigned short、unsigned int、unsigned long我们称其为整型,顾名思义它们可以表达整型数。而能够表达的数值范围与数据类型所占用的字节数有关。数值的表达方法比如简单,如下图所示:
一个字节可以表达0~255,两个字节(unsigned short)自然就可以表达0~65535,依次类推。
当需要把一个整型数值发送出去的时候,我们可以这样作:
unsigned short a=0X1234; 

UART_Send_Byte(((unsigned char *)&a)[0]);

UART_Send_Byte(((unsigned char *)&a)[1]);
也就是将构成整型的若干字节顺序发送即可。当然,接收方一定要知道如何还原数据,也就是说,它要知道自己接收到的若干字节拼在一起是什么类型,这是由具体通信协议来保障的。
unsigned char buf[2];
usnigned short a;

UART_Receive_Byte(buf+0);
UART_Receive_Byte(buf+1);

a=(*(usnigned short *)buf);
OK,关于整型比较容易理解。但换成float,很多人就有些迷糊了。因为float的数值表达方式有些复杂。有些人使用下面的方法来进行浮点的发送:
float a=3.14;

char str[10]={0};

ftoa(str,a); //**浮点转为字符串* *即3.14**转为"3.14"

UART_Send_Str(str); //**通过串口将字符串发出
很显然,这种方法非常的“业余”。

还有人问我:“浮点小数字前后的数字可以发送,但是小数点怎么发?”这赤裸裸的体现了他对浮点类型的误解。

不要被float数值的表象迷惑,它实质上只不过是4个字节而已,如下图所示:

所以,正确的发送浮点数的方法是这样的:

float a=3.14;

UART_Send_Byte(((unsigned char *)&a)[0]);

UART_Send_Byte(((unsigned char *)&a)[1]);

UART_Send_Byte(((unsigned char *)&a)[2]);

UART_Send_Byte(((unsigned char *)&a)[3]);
接收者将数据还原为浮点:
unsigned char buf[4];
float a;

UART_Receive_Byte(buf+0);

UART_Receive_Byte(buf+1);

UART_Receive_Byte(buf+2);

UART_Receive_Byte(buf+3);

a=*((float *)buf);

其实我们应该发现数据类型的实质:不论是什么数据类型,它的基本组成无非就是内存中存储的若干个字节。只是我们人为的赋予了这些字节特定的编码方式或数值表达。看穿了这些,我们就认识到了数据的本质了,我们甚至可以直接操作数据。

八、关于数据的直接操作

直接操作数据?我们来举个例子:取一个整型数的相反数。一般的实现方法是这样的:
int a=10;
int b=-a; //-1*a;
这样的操作可能会涉及到一次乘法运算,花费更多的时间。当我们了解了整型数的实质,就可以这样来作:
int a=10;

int b=(~a)+1;
这也许还不足以说明问题,那我们再来看一个例子:取一个浮点数的相反数。似乎只能这样来作:
float a=3.14;

float b=a*-1.0;
其实,我们可以这样来作:
float a=3.14;
float b;

((unsigned char *)&a)[3]^=0X80;
b=a;

没错,我们可以直接修改浮点在内存中的高字节的符号位。这比乘以-1.0的方法要高效的多。

当然,这些操作都需要你对 C 语言中的指针有炉火纯青的掌握。

九、浮点的四舍五入与比较

我们先说第一个问题:如何实现浮点的四舍五入?很多人遇到过这个问题,其实很简单,只需要把浮点+0.5然后取整即可。
OK,第二个问题:浮点的比较。这个问题还有必要好好说一下。
首先我们要知道,C语言中的判等,即==,是一中强匹配的行为。也就是,比较双方必须每一个位都完全一样,才认定它们相等。这对于整型来说,是可以的。但是float类型则不适用,因为两个看似相等的浮点数,其实它们的内存表达不能保证每一个位都完全一样。
这个时候,我们作一个约定:两个浮点只要它们之差m足够小,则认为它们相等,m一般取10e-6。也就是说,只要两个浮点小数点后6位相同,则认为它们相等。也正是因为这个约定,很多C编译器把float的精度设定为小数点后7位,比如ARMCC(MDK的编译器)。
float a,b;

if(a==b)
... //**错误

if(fabs(a-b) <= 0.000001)
...//**正确

十、出神入化的for循环

for循环我们再熟悉不过了,通常我们使用它都是中规中矩的,如下例:
int i;

for(i=0;i<100;i++)
{...}

但是,如果我们对for循环的本质有更深刻的理解的话,就可以把它用得出神入化。

for后面的括号中的东西我称之为“循环控制体”,分为三个部分,如下图所示:
A、B、C三个部分,其实随意性很大,可以是任意一个表达式。所以,我们可以这样写一个死循环:
for(1;1;1) //1**本身就是一个表达式:常量表达式
{
...
}
当然,我们经常会把它简化成:
for(;;)
{
...
}
既然循环控制体中的A只是在循环开始前作一个初始化的操作,那我这样写应该也没毛病:
int i=0;

for(printf("Number:\r\n");i<10;i++)
{
printf(" %d\r\n",i);
}
B是循环执行的条件,而C是循环执行后的操作,那我们就可以把一个标准的if语句写成for的形式,而实现同样的功能:
if(strstr("hello world!","abc"))
{
printf("Find Sub-string");
}
char *p;

for(p=strstr("hello world!","abc");p;p=NULL)
{
printf("Find Sub-string");
}

以上的例子可能有些鸡肋,“一个if能搞定的事情,我为什么要用for?”,没错。我们这里主要是为了解释for循环的灵活用法。深入理解了它的本质,有助于我们在实际开发中让工作事半功倍,以及看懂别人的代码。

以下我再列举几个for循环灵活应用的例子,供大家回味。
例1:
 char *p; 

for(p="abcdefghijklmnopqrstuvwxyz"; printf(p); p++)
printf("\r\n");

提示:printf我们太熟悉了,但有几个人知道printf是有返回值的?输出应该是怎样的?

例2:
char *p;
unsigned char n;

for(p="ablmnl45ln",n=0;((\*p=='l')?(n++):0),\*p;p++);
提示:还记得C语言中的三目运算和逗号表达式吗?n应该等于几?

例3:

unsigned char *index="C[XMZA[C[NK[RDEX@";

char *alphabet="EHUIRZWXABYPOMQCTGSJDFKLNV ";

int i=0;

for(;(('@'!=index[i])?1:(printf("!!Onz\r\n"),0));i++)
{
printf("%c",alphabet[index[i]-'A']);
}
提示:天书模式已开启。如果看不懂,你可能会错过什么哦!

END

来源:一起学嵌入式

版权归原作者所有,如有侵权,请联系删除。

推荐阅读
你管这破玩意叫CPU?
RTOS实现双核MCU消息通信
入职Linux驱动工程师后,我才知道的真相…

→点关注,不迷路←

文章引用微信公众号"嵌入式微处理器",如有侵权,请联系管理员删除!

博客评论
还没有人评论,赶紧抢个沙发~
发表评论
说明:请文明发言,共建和谐网络,您的个人信息不会被公开显示。