Accelerating code migration from Arm 32-bit to the 64-bit world

Article By : Shawn Prestridge

Transitioning from 32-bit to 64-bit can seem daunting, but it's easier than porting from a 16-bit application to a 32-bit application.

For many companies, a desire to utilize current and emerging technologies rather than legacy devices, which can be at or near their end-of-life stage, is driving the move to the 64-bit world. In general, before taking a design to production, companies seek guarantees from the vendor that the components used have a lifespan. In the case of automotive, that timeframe is usually 10 years.

Alternatively, they seek assurances that they will be notified a minimum of one to two years before the silicon manufacturer will announce the end-of-life of a part. When parts come to the end of production, or in the case of a silicon shortage, developers are forced to choose a different part and often migrate to the latest-and-greatest. Not surprisingly, therefore, many of them are looking to migrate to 64-bit architectures.

Here, at this technology crossroads, it’s worth noting the difference between microcontrollers (MCUs) and microprocessors (MPUs). MCUs have onboard flash and RAM, while the storage and RAM on true MPUs are separated. Today, this distinction is blurring. Most Arm 64-bit MPUs, for example, have onboard RAM, but not necessarily onboard flash. The line blurs even more as most silicon manufacturers now refer to virtually any 64-bit parts they make as microprocessors.

Today, most embedded systems still use 32-bit MCUs, as many companies find that they are sufficient for their applications today and in the foreseeable future. A few companies are starting to use high-end 64-bit MCUs and MPUs. As more features and functionality are added, design teams are forced to consider whether the functionality may outstrip the performance of the legacy microcontroller. For example, a 64-bit device provides more addressable memory, which may result in faster runtimes.

A traditional microcontroller can require a significant amount of bank-switching between the different areas of flash or the other areas of RAM as the amount of addressable space is limited, especially on an 8-bit microcontroller. The process could involve calling up code on a different flash bank, calling the bank-switching code, switching to the other bank, executing, and returning through the bank-switching code. This process can have a deleterious impact on the performance of the code. With 64-bit, the code execution is faster and will remain so as designs become more complex with increased features and functions for quite some time.

Interestingly enough, 64-bit architectures can now be more power-efficient than 32-bit architectures. In some Arm microcontrollers, extra floating-point units were added to provide more efficient number-crunching in a less power-hungry manner than traditional 32-bit solutions. As technology advances, silicon device designers continue to develop new and better ways to reduce power leakage and improve efficiency, which gets baked into new silicon as it emerges. These new power-saving technologies are appearing more in 64-bit devices versus being added to 32-bit architectures.

General guidelines

Transitioning from 32-bit to 64-bit can seem daunting, but it’s easier than porting from a 16-bit application to a 32-bit application. Most well-written C code will work without much modification, although there are some things that developers must be aware of when combing through the C code during the process. In addition, there are differences or changes between the 32-bit and 64-bit data models in the following aspects:

  • Pointer size
  • Size of long integers sign extensions
  • Structure packing
  • Unbalanced size of union members
  • Constant types and constant expressions
  • Format string conversions
  • Portable data types
  • The effects of long arrays on performance
  • Bits and shifts
  • Magic numbers

When it comes to data model types, the principal cause of problems when converting from a 32-bit to a 64-bit application is the change of the size of the integer type for the long and pointer types. When converting a 32-bit program to a 64-bit program, only the long and pointer styles change from 32-bits to 64-bits. The integers of type int stay at 32-bits. This can cause problems with data truncation when you’re assigning a pointer or a long type to an int type. As a result, developers tend to use integers and pointers interchangeably. Sometimes, they use implicit casts, which makes the design more cumbersome.

The most significant difference between 32-bit and 64-bit compilation environments is the change in the data-type models. The C data-type model for 32-bit applications is called the ILP32 model. It’s named that way because the integers, long types, and pointers are all 32-bit data types. The data-type model for 64-bit applications is called LP64, as the long-end pointer types grow to 64 bits.

The most significant difference between 32-bit and 64-bit compilation environments is the change in the data-type models. The C data-type model for 32-bit applications is called the ILP32 model. It’s named that way because the integers, long types, and pointers are all 32-bit data types. The data-type model for 64-bit applications is called LP64, as the long-end pointer types grow to 64 bits.

The 32-bit code relies on the assumption that plain integers and pointers will be the same size. Therefore, it’s common that pointers and integers are cast from one to the other. The problem arises when you try to go to LP64 where integers stay at 32-bit while pointers are now 64-bit. A quick fix that resolves most of these issues is to change the type to the stdint.h definition of uintptr_t, which is an unsigned integer whose size is equal to that of a pointer.

When you do this, it makes your code more portable between the ILP32 and LP64 data models as it puts the onus on compiler to make the appropriate changes in the underlying assembly, and it insulates the code from further changes. If a developer is diligent when changing the 32-bit code to this particular type, it should compile and work the same with the bonus that it will also work correctly in the 64-bit code.

1. Changes of pointer size

The following example shows how to convert pointers by using the 64-bit type correctly. First, let’s see what an incorrect usage might look like in your code.

char *p;

p=(char*) ((int)p & PAGEOFFSET);

//warning: conversion of pointer loses bits

The following will function correctly when compiled to both 32-bit and 64-bit targets:

char *p

p=(char*) ((uintptr_t)p & PAGEOFFSET);

2. Changes in size of long integers

Another common problem with ILP32 code is that it sometimes uses integers and longs interchangeably because they are never clearly distinguished in the ILP32 data-type model. When porting, it’s essential to modify any code that uses integers and longs interchangeably, so it conforms to the requirements of both the older ILP32 model and the newer LP64 data-type model used when moving to 64-bit. While an integer and a long are both 32-bits in the ILP32 data-type model, the long will grow to 64-bits in the LP64. In the following example, a 64-bit compiler will generate a warning that a 64-bit integer is assigned to a 32-bit integer.

Int waiting;

              long w_io;

              longw_swap;    

              …

              waiting=w_io + w_swap

//warning: assignment of 64-bit integer to 32-bit integer

If the compiler doesn’t provide this kind of warning, it’s a sign that your tools allow potential issues with the data models to pass, which can cause errors that lead to problems down the road. These errors are “million-dollar bugs,” as they typically are not found until the device is in the field and a customer runs into a problem. A developer can spend hours or even days trying to recreate and fix the problem.

Sign extension is a widespread problem when converting to 64-bit code because the type and the promotion rules can be somewhat obscure.  Explicit casting can prevent sign extension problems and achieve the intended results. To understand why sign extension occurs in the first place, it helps to understand the conversion rules for ISO/ANSI C.

The conversion rules that appear to cause the most sign extension problems between 32-bit and 64-bit come into effect during different operations. One of them, Integral Promotion, allows the developer to use a char, a short, or a numerated type, or maybe even a bit field in any expression that calls for an integer. If the integer can hold all possible values of the original type, then that value is converted to an integer. Otherwise, that value converts to an unsigned integer which can cause some interesting effects inside the source code.

3. Sign extensions

With sign extension, a developer assigns expressions using types shorter than the size of an integer, which are sign-extended to the longer integer type.

Another case where this can be a problem is the conversion between signed and unsigned integers. When promoting an integer with a negative sign to an unsigned integer of the same or larger type, it is first changed to the signed equivalent of the larger type and then is converted to an unsigned value. This process is defined by the rules of ISO/ANSI C and should not be a compiler-specific behavior. Every compiler should follow this if they are ISO/ANSI Compliant. To prevent sign-extension problems, use explicit casting to achieve the intended result.

Consider the following example: when compiled as a 64-bit program, the address variable will become sign-extended even though both the address and a.base are unsigned types. The addr variable becomes sign-extended, even though both addr and a.base are unsigned types.

struct foo {                        64-bit program

unsigned int base:19,  rehash: 13;            addr 0xffffffff80000000

};                          addr 0x80000000           

main(int argc, char *argv[])

{                           32-bit program

struct foo a;                      addr 0x80000000

a.base = 0x40000;                         addr 0x80000000

addr = a.base << 13;/* Sign extension here! */

   printf(“addr 0x%lx\n”, addr);

addr = (unsigned int)(a.base <<13); /* No sign extensions here! */

   printf(“addr 0x%lx\n”, addr);

}

The sign extension occurs because the conversion rules are applied. The first thing that happens is the structure member, a.base, is converted from an unsigned int bit field to an int because of the integral promotion rule. Because the unsigned 19-bit field will fit within a 32-bit integer, that bit field is promoted to an integer rather than an unsigned integer. The expression a.base, when shifted up by 13, is going to be of type int. Even if the result was assigned to an unsigned int, it doesn’t matter because no sign extension has yet occurred.

But because it can fit inside of a signed integer, it will be converted into a long and then an unsigned long before being assigned to the address variable because of the integer promotion rules. The sign extension occurs when the int converts into a long data type. When compiled as a 64-bit program, several 0xff bytes appear at the beginning of the address variable; however, when compiled as a 32-bit program, there is no sign extension operation. Moving this code to 64-bit causes the sign extension, which was probably not intended. Therefore, it’s crucial to be aware that this may happen.

Also, check the union members’ size because their fields can also change size between the ILP32 and the LP64 data-type model. The conversion can make the size of the members different. Looking at this union, the member D and the member array L will be the same size in the ILP32 model. They will be different in the LP64 because the long type is going to grow to 64 bits. Keep in mind that double types do not change size between ILP32 and LP64. The members’ size can be rebalanced if the type of the I array member changes from long to int.

typedef union {

              double_D;

              long_l[2];

              } llx_

4. Structure packing and size of union members

It is also essential to check the internal data structure of the applications for holes. Frequently, extra padding appears between the fields in the structure to meet alignment requirements, particularly in the Arm world. For 32-bit, it’s typically a 32-bit alignment. If the data members are not accessed on a 32-bit boundary, exceptions occur when executing the code.

It’s possible to let the compiler worry about misaligned accesses, but that typically invokes a larger—and typically slower—runtime library that allows you to access packed structures whose members don’t meet the alignment requirements. However, you have to be aware that there is a size/speed penalty for doing so. IAR DLIB runtime library provides the ability to access packed structures where they’re not aligned.

When switching to the 64-bit model, this alignment need grows to 64-bits and can have a negative impact on the amount of RAM used as well as the speed and execution size of the code. The long and pointer types will be 64-bit aligned in the LP64 model. In the following example, the member p will be 64-bit aligned, so the padding appears between the member k and the member p.

struct bar {                    

            int i;                    

            long j;                     

           int k;                    

           char *p;                    

            }; /* sizeof (struct bar) = 32 bytes */

Also, the structures are aligned to the size of the largest member within them. In this structure, the padding is going to be between member I and member j. When repackaging the structure, make sure to follow the simple rule of moving the long endpoint or fields to the beginning of the structure to help naturally pack the structure.

struct bar {                     

         char *p;                    

            long j;                   

            int i;                   

            int k;                    

            }; /* sizeof (struct bar) = 24 bytes */

5. Constant types and string conversions

A lack of precision can cause data loss in some of constant expressions that may be used. Be explicit when specifying the data types in constant expressions. Specify the type of each integer by adding a combination of U or L. Casting can be used to specify the type of a constant expression.

int I = 32;

              long j = 1 << I; /*j will get 0 because RHS is integer expression */

In this example, it can work as intended. You append a type to the integer. This is a common problem that can be misinterpreted as a bug in the compiler, while in reality, the compiler is following the ISO/ANSI C rules.

int I =32:

long j = 1L <<I; /* now j will get 0x100000000, as intended *

If you’re getting a weird answer with a bunch of leading 0xff bytes, there’s a good chance that a constant expression exists that needs to be explicitly promoted to either a long or an unsigned type. Keep in mind that what works in a 32-bit world may suddenly elicit a bug in the 64-bit world when doing constant expressions because the size of the constant expression changes.

In addition, make sure the format strings for printf, sprintf, and scanf can accommodate long or pointer arguments. For pointer arguments, the conversion operation given in the format string should be %p to work in both the 32-bit and 64-bit compilation environments. For long arguments, the long size specification (l) should be prepended to the conversion operation character in the format string. For example, a pointer expressed by eight hex digits in the ILP32 data model will expand to 16 in the LP64 model. The buffers passed to the first argument in the sprintf must contain enough storage to accommodate the expanded number of digits used to convey long and pointer values.

If data structures are shared between 32-bit and 64-bit versions of an application, stay with data types that have a common size between ILP32 and LP64. To make your code more portable, avoid using the long data types and pointers as much as possible. Avoid any derived data types that are going to change in size between 32-bit and 64-bit applications. The types that are in types.h will change in size between ILP32 and LP64 models.

Using derived data types and types.h is a good idea for internal data as it helps insulate the code from any sort of data model changes. But because the sizes of those types are prone to change with the data model, it is not recommended to use them for data that will be shared between the 32-bit and 64-bit applications or if the data size must remain fixed.

As with the size of the operator, make sure that any changes to the code will have any practical impact on the system. Therefore, for any binary interface data, consider using the fixed width integer types that are defined in types.h. These are good for explicit binary representations of memory data, over-the-wire data, and any sort of hardware registers or binary data structures that might be used.

Large arrays of long or unsigned types can cause severe performance degradation in the LP64 data model, especially when you are using arrays of signed or unsigned ints.

Large long arrays can use significantly more cache, which can cause cache misses and consume more memory. So, if int works for a particular application’s purposes, it’s better to use an int, rather than a long. There is an argument for using arrays of types, instead of an array of pointers. When using arrays of pointers and moving into the 64-bit world, they automatically become 64-bit values instead of 32-bit values. Some C applications will suffer from that performance hit when converting to the LP64 data type because they rely on many large arrays of pointers. If you are using arrays of pointers, consider whether or not the full 64-bit pointer size is needed or whether a 32-bit will suffice. Then write a wrapper function that converts to the 64-bit pointer value.

6. Bits and shifts

It’s easy to make an error in the code when working with separate bits. The error types in Figure 1 relate to a shift operation. In this example, the code will work well on a 32-bit architecture and allow the developer to set bits from 0 to 31 to unity.

sptrdiff_t SetBitN(ptrdiff_t value, unsigned bitNum) {

  ptrdiff_t mask = 1 << bitNum;

  return value | mask;

Figure 1 The image shows error types that relate to a shift operation. Source: IAR Systems

Figure 2 shows what happens when you move this code to a 64-bit data model. This code will never set bits 32 to 63, so you need to figure out what the original intention was in the code you’re porting. Do you need all the bits to be set to 1, or should it only be the first 32?

ptrdiff_t mask = ptrdiff_t(1) << bitNum;

Figure 2 This is what happens when you migrate the code to a 64-bit data model. Source: IAR Systems

Remember, incorrect code leads to more than one error. When 32-bits are set on a 64-bit system, the result of the function will have leading 0xff bytes. The result of the one shifted up by 31 bits will be a negative number in the 64-bit integer variable.

7. Magic numbers

Magic numbers, as shown in Figure 3, are hard-coded numbers inside an application that assume the application is running in a 32-bit world.

size_t ArraySize = N * 4;

size_t *Array = (size_t *)malloc(ArraySize);

Figure 3 Magic numbers assume that the application is running in a 32-bit world. Source: IAR Systems

If the number four appears in the application, it’s crucial to make sure that someone does not believe that every entry inside of a pointer will be four bytes wide. For that same reason, the magic value of 32, the number of bits inside a type, should be examined to be sure the developer does not make that assumption. Others may not be quite so obvious. For example, 0X7f, followed by 0xff bytes, is the maximum value of an assigned variable or a mask for higher bits.

Another one is 0x80000000, the minimum value of an assigned variable and a mask that’s typically used for higher bit selection capabilities, or 0xffffffff up to 32 bits, the maximum value of an unsigned variable. If these types appear as numbers inside the code, they indicate that the developer should examine the code in more depth to ensure it’s ready to be ported to 64-bit. Otherwise, it can become a source of errors.

Tool selection considerations

A significant consideration is the tools used and the manufacturer of the tools, as they make a tremendous difference in the effort needed to port your existing code base to 64-bit. Consider the following aspects:

  • Development libraries and dependence on third-party libraries like GUI libraries for middleware, RTOS, or other things
  • 64-bit compiler support
  • 64-bit assembler code support
  • 64-bit debugging with support for the complete instruction set
  • Knowledge of 64-bit programming rules acquired by your development staff

In addition, consider the skillset of the development team. If they don’t have much experience working with 64-bit architectures and working within the LP64 data environment, it can cause some interesting effects while they learn some of these rules the hard way when they start trying to port code. If they use higher-quality tools, they can usually learn these rules more quickly because the tools issue descriptive error and warning messages that elucidate the underlying issue. Moreover, higher-quality tools have real-time support people who can help you interpret any messages you don’t understand, so your team doesn’t just disable warnings that really are bugs waiting to bite you.

For compilers, you should give preference to a tool with 32-bit and 64-bit core solutions provided by a compiler company with a long track record of innovation across multiple platforms and services developers need. Finally, tools should be functional safety-certified if the end product requires a functional safety certification.

Using a large amount of assembler code can increase the cost of migration to 64-bit models as many tools don’t offer inline assemblers. In that case, consider porting that code from assembler into C or C++ to future-proof the code. Developers write their inline assembler code for a variety of reasons, but oftentimes it’s to make up for an inefficient compiler. Remember that assembler code is not portable from architecture to architecture, making it challenging to ensure that internally developed code fits in with a new architecture. Essentially, assembler is the very definition of non-portable code.

Furthermore, 64-bit debugging capability with full 64-bit instruction support is essential because the quality of your end product will only be as good as the debugging capabilities of the toolchain to find issues. Developing a multicore application can be a nightmare without a high-quality debugger that understands cross trigger interfaces between the different cores.

Your tools should also have smart features like complex breakpoints (with both code and data), code profiling, and code coverage. Moreover, you should have data visualization, power usage visualization, and visual interrupt flows through an easy-to-understand timeline window. The 64-bit boards are very costly, so having an accurate 64-bit core simulator can help save some time and money as well by allowing you to develop and debug code on the simulator rather than always needing access to a board.

Plan your 64-bit migration

Eventually, moving to 64-bits is going to become a necessity, so planning now is a wise move. Porting legacy code from 32-bit to 64-bit requires knowledge of the data types and the C and C++ rules that govern ILP32 and LP64. Using quality tools can help developers find and fix defects quickly and help port the code more quickly.

This article was originally published on EDN.

Shawn Prestridge is U.S. field application engineers team manager at IAR Systems.

 

Leave a comment