Dive into Systems
Authors
Suzanne J. Matthews, Ph.D. — West Point
[email protected]
Tia Newhall, Ph.D. — Swarthmore College
[email protected]
Kevin C. Webb, Ph.D. — Swarthmore College
[email protected]
Book Version
Dive into Systems — Version 1.2
Copyright
© 2020 Dive into Systems, LLC
License: CC BY-NC-ND 4.0
This work is licensed under the Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International.
Disclaimer
The authors made every effort to ensure that the information in this book was correct. The programs in this book have been included for instructional purposes only. The authors do not offer any warranties with respect to the programs or contents of this book. The authors do not assume and hereby disclaim any liability to any party for any loss, damage, or disruption caused by errors or omissions, whether such errors or omissions result from negligence, accident, or any other cause.
The views expressed in this book are those of the authors and do not reflect the official policy or position of the Department of the Army, Department of Defense, or the U.S. Government.
Acknowledgements
The authors would like to acknowledge the following individuals for helping make Dive into Systems a success:
Formal Reviewers
Each chapter in Dive into Systems was peer-reviewed by several CS professors around the United States. We are extremely grateful to those faculty who served as formal reviewers. Your insight, time, and recommendations have improved the rigor and precision of Dive into Systems. Specifically, we would like to acknowledge the contributions of:
-
Jeannie Albrecht (Williams College) for her review and feedback on Chapter 15.
-
John Barr (Ithaca College) for his review and feedback on chapters 6, 7, and 8, and providing general advice for the x86_64 chapter.
-
Jon Bentley for providing review and feedback on section 5.1, including line-edits.
-
Anu G. Bourgeois (Georgia State University) for her review and feedback on Chapter 4.
-
Martina Barnas (Indiana University Bloomington) for her review and insightful feedback on Chapter 14, especially section 14.4.
-
David Bunde (Knox College) for his review, comments and suggestions on Chapter 14.
-
Stephen Carl (Sewanee: The University of the South) for his careful review and detailed feedback on chapters 6 and 7.
-
Bryan Chin (U.C. San Diego) for his insightful review of the ARM assembly chapter (chapter 9).
-
Amy Csizmar Dalal (Carleton College) for her review and feedback on Chapter 5.
-
Debzani Deb (Winston-Salem State University) for her review and feedback on Chapter 11.
-
Saturnino Garcia (University of San Diego) for his review and feedback on Chapter 5.
-
Tim Haines (University of Wisconsin) for his comments and review of Chapter 3.
-
Bill Jannen (Williams College) for his detailed review and insightful comments on Chapter 11.
-
Ben Marks (Swarthmore College) for comments on chapters 1 and 2.
-
Alexander Mentis (West Point) for insightful comments and line-edits of early drafts of this book.
-
Rick Ord (U.C. San Diego) for his review and suggested corrections for the Preface, and reviewing over 60% (!!) of the book, including chapters 0, 1, 2, 3, 4, 6, 7, 8 and 14. His feedback has helped us keep our notation and code consistent over the different chapters!
-
Joe Politz (U.C. San Diego) for his review and detailed suggestions for strengthening Chapter 12.
-
Brad Richards (University of Puget Sound) for his rapid feedback and suggestions for Chapter 12.
-
Kelly Shaw (Williams College) for her review and suggestions for Chapter 15.
-
Simon Sultana (Fresno Pacific University) for his review and suggested corrections for Chapter 1.
-
Cynthia Taylor (Oberlin College) for her review and suggested corrections of Chapter 13.
-
David Toth (Centre College) for his review and suggested corrections for Chapters 2 and 14.
-
Bryce Wiedenbeck (Davidson College) for his review and suggested corrections for Chapter 4.
-
Daniel Zingaro (University of Toronto Mississauga) for catching so many typos.
Additional Feedback
The following people caught random typos and other sundries. We are grateful for your help in finding typos!
-
Kevin Andrea (George Mason University)
-
Tanya Amert (Denison University)
-
Ihor Beliuha
-
Christiaan Biesterbosch
-
Daniel Canas (Wake Forest University)
-
Chien-Chung Shen (University of Delaware)
-
Vasanta Chaganti (Swarthmore College)
-
Stephen Checkoway (Oberlin College)
-
Jocelyn Corey (Swarthmore College)
-
John DeGood (The College of New Jersey)
-
Joe Errey
-
Artin Farahani
-
Sat Garcia (University of San Diego)
-
Aaron Gember-Jacobson (Colgate University)
-
Stephen Gilbert
-
Arina Kazakova (Swarthmore College)
-
Akiel Khan
-
Deborah Knox (The College of New Jersey)
-
Ivan Kolomiiets
-
Kevin Lahey (Colgate University)
-
Raphael Matchen
-
Sivan Nachaum (Smith College)
-
Aline Normolye (Bryn Mawr College)
-
SaengMoung Park (Swarthmore College)
-
Rodrigo Piovezan (Swarthmore College)
-
Roy Ragsdale (West Point) who gave advice for restructuring the guessing game for the ARM buffer overflow exploit in chapter 9.
-
Zachary Robinson (Swarthmore College)
-
Joel Sommers (Colgate University)
-
Peter Stenger
-
Richard Weiss (Evergreen State College)
-
David Toth (Centre College)
-
Marcos Vinicius
-
Alyssa Zhang (Swarthmore College)
Early Adopters
An alpha release of Dive into Systems was piloted at West Point in Fall 2018; The beta release of the textbook was piloted at West Point and Swarthmore College in Spring 2019. In Fall 2019, Dive into Systems launched its Early Adopter Program, which enabled faculty around the United States to pilot the stable release of Dive into Systems at their institutions. The Early Adopter Program is a huge help to the authors, as it helps us get valuable insight into student and faculty experiences with the textbook. We use the feedback we receive to improve and strengthen the content of Dive into Systems, and are very thankful to everyone who completed our student and faculty surveys.
2019-2020 Early Adopters
The following individuals piloted Dive into Systems as a textbook at their institutions during the Fall 2019- Spring 2020 Academic Year:
-
John Barr (Ithaca College) - Computer Organization & Assembly Language (Comp 210)
-
Chris Branton (Drury University) - Computer Systems Concepts (CSCI 342)
-
Dick Brown (St. Olaf College) - Hardware Design (CSCI 241)
-
David Bunde (Knox College) - Introduction to Computing Systems (CS 214)
-
Bruce Char (Drexel University) - Systems Programming (CS 283)
-
Vasanta Chaganti (Swarthmore College) - Introduction to Computer Systems (CS 31)
-
Bryan Chin (U.C. San Diego) - Computer Organization and Systems Programming (CSE 30)
-
Stephen Carl (Sewanee: The University of the South) - Computer Systems and Organization (CSci 270)
-
John Dougherty (Haverford College) - Computer Organization (cs240)
-
John Foley (Smith College) - Operating Systems (CSC 262)
-
Elizabeth Johnson (Xavier University) - Programming in C
-
Alexander Kendrowitch (West Point) - Computer Organization (CS380)
-
Bill Kerney (Clovis Community College) - Assembly Programming (CSCI 45)
-
Deborah Knox (The College of New Jersey) - Computer Architecture (CSC 325)
-
Doug MacGregor (Western Colorado University) - Operating Systems/Architecture (CS 330)
-
Jeff Matocha (Ouachita Baptist University) - Computer Organization (CSCI 3093)
-
Keith Muller (U.C. San Diego) - Computer Organization and Systems Programming (CSE 30)
-
Crystal Peng (Park University) - Computer Architecture (CS 319)
-
Leo Porter (U.C. San Diego) - Introduction to Computer Architecture (CSE 141)
-
Lauren Provost (Simmons University) - Computer Architecture and Organization (CS 226)
-
Kathleen Riley (Bryn Mawr College) - Principles of Computer Organization (CMSC B240)
-
Roger Shore (High Point University) - Computer Systems (CSC-2410)
-
Tony Tong (Wheaton College, Norton MA) - Advanced Topics in Computer Science: Parallel and Distributed Computing (COMP 398)
-
Brian Toone (Samford University) - Computer Organization and Architecture (COSC 305)
-
David Toth (Centre College) - Systems Programming (CSC 280)
-
Bryce Wiedenbeck (Davidson College) - Computer Organization (CSC 250)
-
Richard Weiss (The Evergreen State College) - Computer Science Foundations: Computer Architecture (CSF)
Preface
In today’s world, much emphasis is placed on learning to code, and programming is touted as a golden ticket to a successful life. Despite all the code boot camps and programming being taught in elementary schools, the computer itself is often treated as an afterthought — it’s increasingly becoming invisible in the discussions of raising the next generations of computer scientists.
The purpose of this book is to give readers a gentle yet accessible introduction to computer systems. To write effective programs, programmers must understand a computer’s underlying subsystems and architecture. However, the expense of modern textbooks often limits their availability to the set of students that can afford them. This free online textbook seeks to make computer systems concepts accessible to everyone. It is targeted toward students with an introductory knowledge of computer science who have some familiarity with Python. If you’re looking for a free book to introduce you to basic computing principles in Python, we encourage you to read How To Think Like a Computer Scientist with Python first.
If you’re ready to proceed, please come in — the water is warm!
What This Book Is About
Our book is titled Dive into Systems and is meant to be a gentle introduction to topics in computer systems, including C programming, architecture fundamentals, assembly language, and multithreading. The ocean metaphor is very fitting for computer systems. As modern life is thought to have risen from the depths of the primordial ocean, so has modern programming risen from the design and construction of early computer architecture. The first programmers studied the hardware diagrams of the first computers to create the first programs.
Yet as life (and computing) began to wander away from the oceans from which they emerged, the ocean began to be perceived as a foreboding and dangerous place, inhabited by monsters. Ancient navigators used to place pictures of sea monsters and other mythical creatures in the uncharted waters. Here be dragons, the text would warn. Likewise, as computing has wandered ever further away from its machine-level origins, computer systems topics have often emerged as personal dragons for many computing students.
In writing this book, we hope to encourage students to take a gentle dive into computer systems topics. Even though the sea may look like a dark and dangerous place from above, there is a beautiful and remarkable world to be discovered for those who choose to peer just below the surface. So too can a student gain a greater appreciation for computing by looking below the code and examining the architectural reef below.
We are not trying to throw you into the open ocean here. Our book assumes only a CS1 knowledge and is designed to be a first exposure to many computer systems topics. We cover topics such as C programming, logic gates, binary, assembly, the memory hierarchy, threading, and parallelism. Our chapters are written to be as independent as possible, with the goal of being widely applicable to a broad range of courses.
Lastly, a major goal for us writing this book is for it to be freely available. We want our book to be a living document, peer reviewed by the computing community, and evolving as our field continues to evolve. If you have feedback for us, please drop us a line. We would love to hear from you!
Ways to Use This Book
Our textbook covers a broad range of topics related to computer systems, specifically targeting intermediate-level courses such as introduction to computer systems or computer organization. It can also be used to provide background reading for upper-level courses such as operating systems, compilers, parallel and distributed computing, and computer architecture.
This textbook is not designed to provide complete coverage of all systems topics. It does not include advanced or full coverage of operating systems, computer architecture, or parallel and distributed computing topics, nor is it designed to be used in place of textbooks devoted to advanced coverage of these topics in upper-level courses. Instead, it focuses on introducing computer systems, common themes in systems in the context of understanding how a computer runs a program, and how to design programs to run efficiently on systems. The topic coverage provides a common knowledge base and skill set for more advanced study in systems topics.
Our book’s topics can be viewed as a vertical slice through a computer. At the lowest layer we discuss binary representation of programs and circuits designed to store and execute programs, building up a simple CPU from basic gates that can execute program instructions. At the next layer we introduce the operating system, focusing on its support for running programs and for managing computer hardware, particularly on the mechanisms of implementing multiprogramming and virtual memory support. At the highest layer, we present the C programming language and how it maps to low-level code, how to design efficient code, compiler optimizations, and parallel computing. A reader of the entire book will gain a basic understanding of how a program written in C (and Pthreads) executes on a computer and, based on this understanding, will know some ways in which they can change the structure of their program to improve its performance.
Although as a whole the book provides a vertical slice through the computer, the book chapters are written as independently as possible so that an instructor can mix and match chapters for their particular needs. The chapter dependency graph is shown below, though individual sections within chapters may not have as deep a dependency hierarchy as the entire chapter.
Summary of Chapter Topics
-
Chapter 0, Introduction: Introduction to computer systems and some tips for reading this book.
-
Chapter 1, Introduction to C Programming: Covers C programming basics, including compiling and running C programs. We assume readers of this book have had an introduction to programming in some programming language. We compare example C syntax to Python syntax so that readers familiar with Python can see how they may translate. However, Python programming experience is not necessary for reading or understanding this chapter.
-
Chapter 2, A Deeper Dive into C: Covers most of the C language, notably pointers and dynamic memory. We also elaborate on topics from Chapter 1 in more detail and discuss some advanced C features.
-
Chapter 3, C Debugging Tools: Covers common C debugging tools (GDB and Valgrind) and illustrates how they can be used to debug a variety of applications.
-
Chapter 4, Binary and Data Representation: Covers encoding data into binary, binary representation of C types, arithmetic operations on binary data, and arithmetic overflow.
-
Chapter 5, Gates, Circuits, and Computer Architecture: Covers the von Neumann architecture from logic gates to the construction of a basic CPU. We characterize clock-driven execution and the stages of instruction execution though arithmetic, storage, and control circuits. We also briefly introduce pipelining, some modern architecture features, and a short history of computer architecture.
-
Chapters 6-10, Assembly Programming: Covers translating C into assembly code from basic arithmetic expressions to functions, the stack, and array and
structaccess. In three separate chapters we cover assembly from three different instruction set architectures: 32-bit x86, 64-bit x86, and 64-bit ARM. -
Chapter 11, Storage and the Memory Hierarchy: Covers storage devices, the memory hierarchy and its effects on program performance, locality, caching, and the Cachegrind profiling tool.
-
Chapter 12, Code Optimization: Covers compiler optimizations, designing programs with performance in mind, tips for code optimization, and quantitatively measuring a program’s performance.
-
Chapter 13, Operating Systems: Covers core operating system abstractions and the mechanisms behind them. We primarily focus on processes, virtual memory, and interprocess communication (IPC).
-
Chapter 14, Shared Memory Parallelism: Covers multicore processors, threads and Pthreads programming, synchronization, race conditions, and deadlock. This chapter includes some advanced topics on measuring parallel performance (speed-up, efficiency, Amdahl’s law), thread safety, and cache coherence.
-
Chapter 15, Advanced Parallel Systems and Programming Models: Introduces the basics of distributed memory systems and the Message Passing Interface (MPI), hardware accelerators and CUDA, and cloud computing and MapReduce.
Example Uses of This Book
Dive into Systems can be used as a primary textbook for courses that introduce computer systems topics, or individual chapters can be used to provide background information in courses that cover topics in more depth.
As examples from the authors' two institutions, we have been using it as the primary textbook for two different intermediate-level courses:
-
Introduction To Computer Systems at Swarthmore College. Chapter ordering: 4, 1 (some 3), 5, 6, 7, 10, 2 (more 3), 11, 13, 14.
-
Computer Organization at West Point. Chapter ordering: 1, 4, 2 (some 3), 6, 7, 10, 11, 12, 13, 14, 15.
Additionally, we use individual chapters as background reading in many of our upper-level courses, including:
| Upper-level Course Topic | Chapters for Background Readings |
|---|---|
Architecture |
5, 11 |
Compilers |
6, 7, 8, 9, 10, 11, 12 |
Database Systems |
11, 14, 15 |
Networking |
4, 13, 14 |
Operating Systems |
11, 13, 14 |
Parallel and Distributed Systems |
11, 13, 14, 15 |
Finally, Chapters 2 and 3 are used as C programming and debugging references in many of our courses.
Available Online
The free online version of our textbook is available at https://diveintosystems.org/.
0. Introduction
Dive into the fabulous world of computer systems! Understanding what a computer system is and how it runs your programs can help you to design code that runs efficiently and that can make the best use of the power of the underlying system. In this book, we take you on a journey through computer systems. You will learn how your program written in a high-level programming language (we use C) executes on a computer. You will learn how program instructions translate into binary and how circuits execute their binary encoding. You will learn how an operating system manages programs running on the system. You will learn how to write programs that can make use of multicore computers. Throughout, you will learn how to evaluate the systems costs associated with program code and how to design programs to run efficiently.
What Is a Computer System?
A computer system combines the computer hardware and special system software that together make the computer usable by users and programs. Specifically, a computer system has the following components (see Figure 1):
-
Input/output (IO) ports enable the computer to take information from its environment and display it back to the user in some meaningful way.
-
A central processing unit (CPU) runs instructions and computes data and memory addresses.
-
Random access memory (RAM) stores the data and instructions of running programs. The data and instructions in RAM are typically lost when the computer system loses power.
-
Secondary storage devices like hard disks store programs and data even when power is not actively being provided to the computer.
-
An operating system (OS) software layer lies between the hardware of the computer and the software that a user runs on the computer. The OS implements programming abstractions and interfaces that enable users to easily run and interact with programs on the system. It also manages the underlying hardware resources and controls how and when programs execute. The OS implements abstractions, policies, and mechanisms to ensure that multiple programs can simultaneously run on the system in an efficient, protected, and seamless manner.
The first four of these define the computer hardware component of a computer system. The last item (the operating system) represents the main software part of the computer system. There may be additional software layers on top of an OS that provide other interfaces to users of the system (e.g., libraries). However, the OS is the core system software that we focus on in this book.
We focus specifically on computer systems that have the following qualities:
-
They are general purpose, meaning that their function is not tailored to any specific application.
-
They are reprogrammable, meaning that they support running a different program without modifying the computer hardware or system software.
To this end, many devices that may "compute" in some form do not fall into the category of a computer system. Calculators, for example, typically have a processor, limited amounts of memory, and I/O capability. However, calculators typically do not have an operating system (advanced graphing calculators like the TI-89 are a notable exception to this rule), do not have secondary storage, and are not general purpose.
Another example that bears mentioning is the microcontroller, a type of integrated circuit that has many of the same capabilities as a computer. Microcontrollers are often embedded in other devices (such as toys, medical devices, cars, and appliances), where they control a specific automatic function. Although microcontrollers are general purpose, reprogrammable, contain a processor, internal memory, secondary storage, and are I/O capable, they lack an operating system. A microcontroller is designed to boot and run a single specific program until it loses power. For this reason, a microcontroller does not fit our definition of a computer system.
What Do Modern Computer Systems Look Like?
Now that we have established what a computer system is (and isn’t), let’s discuss what computer systems typically look like. Figure 2 depicts two types of computer hardware systems (excluding peripherals): a desktop computer (left) and a laptop computer (right). A U.S. quarter on each device gives the reader an idea of the size of each unit.
Notice that both contain the same hardware components, though some of the components may have a smaller form factor or be more compact. The DVD/CD bay of the desktop was moved to the side to show the hard drive underneath — the two units are stacked on top of each other. A dedicated power supply helps provide the desktop power.
In contrast, the laptop is flatter and more compact (note that the quarter in this picture appears a bit bigger). The laptop has a battery and its components tend to be smaller. In both the desktop and the laptop, the CPU is obscured by a heavyweight CPU fan, which helps keep the CPU at a reasonable operating temperature. If the components overheat, they can become permanently damaged. Both units have dual inline memory modules (DIMM) for their RAM units. Notice that laptop memory modules are significantly smaller than desktop modules.
In terms of weight and power consumption, desktop computers typically consume 100 - 400 W of power and typically weigh anywhere from 5 to 20 pounds. A laptop typically consumes 50 - 100 W of power and uses an external charger to supplement the battery as needed.
The trend in computer hardware design is toward smaller and more compact devices. Figure 3 depicts a Raspberry Pi single-board computer. A single-board computer (SBC) is a device in which the entirety of the computer is printed on a single circuit board.
The Raspberry Pi SBC contains a system-on-a-chip (SoC) processor with integrated RAM and CPU, which encompasses much of the laptop and desktop hardware shown in Figure 2. Unlike laptop and desktop systems, the Raspberry Pi is roughly the size of a credit card, weighs 1.5 ounces (about a slice of bread), and consumes about 5 W of power. The SoC technology found on the Raspberry Pi is also commonly found in smartphones. In fact, the smartphone is another example of a computer system!
Lastly, all of the aforementioned computer systems (Raspberry Pi and smartphones included) have multicore processors. In other words, their CPUs are capable of executing multiple programs simultaneously. We refer to this simultaneous execution as parallel execution. Basic multicore programming is covered in Chapter 14 of this book.
All of these different types of computer hardware systems can run one or more general purpose operating systems, such as macOS, Windows, or Unix. A general-purpose operating system manages the underlying computer hardware and provides an interface for users to run any program on the computer. Together these different types of computer hardware running different general-purpose operating systems make up a computer system.
What You Will Learn In This Book
By the end of this book, you will know the following:
How a computer runs a program: You will be able to describe, in detail, how a program expressed in a high-level programming language gets executed by the low-level circuitry of the computer hardware. Specifically, you will know:
-
how program data gets encoded into binary and how the hardware performs arithmetic on it
-
how a compiler translates C programs into assembly and binary machine code (assembly is the human-readable form of binary machine code)
-
how a CPU executes binary instructions on binary program data, from basic logic gates to complex circuits that store values, perform arithmetic, and control program execution
-
how the OS implements the interface for users to run programs on the system and how it controls program execution on the system while managing the system’s resources.
How to evaluate systems costs associated with a program’s performance: A program runs slowly for a number of reasons. It could be a bad algorithm choice or simply bad choices on how your program uses system resources. You will understand the Memory Hierarchy and its effects on program performance, and the operating systems costs associated with program performance. You will also learn some valuable tips for code optimization. Ultimately, you will be able to design programs that use system resources efficiently, and you will know how to evaluate the systems costs associated with program execution.
How to leverage the power of parallel computers with parallel programming: Taking advantage of parallel computing is important in today’s multicore world. You will learn to exploit the multiple cores on your CPU to make your program run faster. You will know the basics of multicore hardware, the OS’s thread abstraction, and issues related to multithreaded parallel program execution. You will have experience with parallel program design and writing multithreaded parallel programs using the POSIX thread library (Pthreads). You will also have an introduction to other types of parallel systems and parallel programming models.
Along the way, you will also learn many other important details about computer systems, including how they are designed and how they work. You will learn important themes in systems design and techniques for evaluating the performance of systems and programs. You’ll also master important skills, including C and assembly programming and debugging.
Getting Started with This Book
A few notes about languages, book notation, and recommendations for getting started reading this book:
Linux, C, and the GNU Compiler
We use the C programming language in examples throughout the book. C is a high-level programming language like Java and Python, but it is less abstracted from the underlying computer system than many other high-level languages. As a result, C is the language of choice for programmers who want more control over how their program executes on the computer system.
The code and examples in this book are compiled using the GNU C Compiler (GCC) and run on the Linux operating system. Although not the most common mainstream OS, Linux is the dominant OS on supercomputing systems and is arguably the most commonly used OS by computer scientists.
Linux is also free and open source, which contributes to its popular use in these settings. A working knowledge of Linux is an asset to all students in computing. Similarly, GCC is arguably the most common C compiler in use today. As a result, we use Linux and GCC in our examples. However, other Unix systems and compilers have similar interfaces and functionality.
In this book, we encourage you to type along with the listed examples. Linux commands appear in blocks like the following:
$
The $ represents the command prompt. If you see a box that looks
like
$ uname -a
this is an indication to type uname -a on the command line. Make sure that
you don’t type the $ sign!
The output of a command is usually shown directly after the command in a command line listing.
As an example, try typing in uname -a. The output of this command varies from system to
system. Sample output for a 64-bit system is shown here.
$ uname -a Linux Fawkes 4.4.0-171-generic #200-Ubuntu SMP Tue Dec 3 11:04:55 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
The uname command prints out information about a particular system. The -a flag prints out all relevant information associated with the system
in the following order:
-
The kernel name of the system (in this case Linux)
-
The hostname of the machine (e.g., Fawkes)
-
The kernel release (e.g., 4.4.0-171-generic)
-
The kernel version (e.g., #200-Ubuntu SMP Tue Dec 3 11:04:55 UTC 2019)
-
The machine hardware (e.g., x86_64)
-
The type of processor (e.g., x86_64)
-
The hardware platform (e.g., x86_64)
-
The operating system name (e.g., GNU/Linux)
You can learn more about the uname command or any other Linux command by prefacing the command with man,
as shown here:
$ man uname
This command brings up the manual page associated with the uname command. To quit out of this interface,
press the q key.
While a detailed coverage of Linux is beyond the scope of this book, readers can get a good introduction in the online Appendix 2 - Using UNIX. There are also several online resources that can give readers a good overview. One recommendation is "The Linux Command Line"1.
Other Types of Notation and Callouts
Aside from the command line and code snippets, we use several other types of "callouts" to represent content in this book.
The first is the aside. Asides are meant to provide additional context to the text, usually historical. Here’s a sample aside:
The second type of callout we use in this text is the note. Notes are used to highlight important information, such as the use of certain types of notation or suggestions on how to digest certain information. A sample note is shown below:
|
How to do the readings in this book
As a student, it is important to do the readings in the textbook. Notice that we say "do" the readings, not simply "read" the readings. To "read" a text typically implies passively imbibing words off a page. We encourage students to take a more active approach. If you see a code example, try typing it in! It’s OK if you type in something wrong, or get errors; that’s the best way to learn! In computing, errors are not failures — they are simply experience. |
The last type of callout that students should pay specific attention to is the warning. The authors use warnings to highlight things that are common "gotchas" or a common cause of consternation among our own students. Although all warnings may not be equally valuable to all students, we recommend that you review warnings to avoid common pitfalls whenever possible. A sample warning is shown here:
|
This book contains puns
The authors (especially the first author) are fond of puns and musical parodies related to computing (and not necessarily good ones). Adverse reactions to the authors' sense of humor may include (but are not limited to) eye-rolling, exasperated sighs, and forehead slapping. |
If you are ready to get started, please continue on to the first chapter as we dive into the wonderful world of C. If you already know some C programming, you may want to start with Chapter 4 on binary representation, or continue with more advanced C programming in Chapter 2.
We hope you enjoy your journey with us!
References
-
William Shotts. "The Linux Command Line", LinuxCommand.org, https://linuxcommand.org/
1. By the C, by the C, by the Beautiful C
"By the Beautiful Sea", Carroll and Atteridge, 1914
This chapter presents an overview of C programming written for students who have some experience programming in another language. It’s specifically written for Python programmers and uses a few Python examples for comparison purposes (Appendix 1 is a version of Chapter 1 for Java programmers). However, it should be useful as an introduction to C programming for anyone with basic programming experience in any language.
C is a high-level programming language like other languages you might know,
such as Python, Java, Ruby, or C++. It’s an imperative and a procedural
programming language, which means that a C program is expressed as a sequence
of statements (steps) for the computer to execute and that C programs are
structured as a set of functions (procedures). Every C program must have at
least one function, the main function, which contains the set of statements
that execute when the program begins.
The C programming language is less abstracted from the computer’s machine language than some other languages with which you might be familiar. This means that C doesn’t have support for object-oriented programming (like Python, Java, and C++) or have a rich set of high-level programming abstractions (such as strings, lists, and dictionaries in Python). As a result, if you want to use a dictionary data structure in your C program, you need to implement it yourself, as opposed to just importing the one that is part of the programming language (as in Python).
C’s lack of high-level abstractions might make it seem like a less appealing programming language to use. However, being less abstracted from the underlying machine makes C easier for a programmer to see and understand the relationship between a program’s code and the computer’s execution of it. C programmers retain more control over how their programs execute on the hardware, and they can write code that runs more efficiently than equivalent code written using the higher-level abstractions provided by other programming languages. In particular, they have more control over how their programs manage memory, which can have a significant impact on performance. Thus, C remains the de facto language for computer systems programming where low-level control and efficiency are crucial.
We use C in this book because of its expressiveness of program control and its relatively straightforward translation to assembly and machine code that a computer executes. This chapter introduces programming in C, beginning with an overview of its features. Chapter 2 then describes C’s features in more detail.
1.1. Getting Started Programming in C
Let’s start by looking at a "hello world" program that includes an example of
calling a function from the math library. In Table 1 we compare the C
version of this program to the Python version. The C version might be put in a
file named hello.c (.c is the suffix convention for C source code files),
whereas the Python version might be in a file named hello.py.
| Python version (hello.py) | C version (hello.c) |
|---|---|
|
|
Notice that both versions of this program have similar structure and language constructs, albeit with different language syntax. In particular:
Comments:
-
In Python, multiline comments begin and end with
''', and single-line comments begin with#. -
In C, multiline comments begin with
/*and end with*/, and single-line comments begin with//.
Importing library code:
-
In Python, libraries are included (imported) using
import. -
In C, libraries are included (imported) using
#include. All#includestatements appear at the top of the program, outside of function bodies.
Blocks:
-
In Python, indentation denotes a block.
-
In C, blocks (for example, function, loop, and conditional bodies) start with
{and end with}.
The main function:
-
In Python,
def main():defines the main function. -
In C,
int main(void){ }defines the main function. Themainfunction returns a value of typeint, which is C’s name for specifying the signed integer type (signed integers are values like -3, 0, 1234). Themainfunction returns theintvalue 0 to signify running to completion without error. Thevoidmeans it doesn’t expect to receive a parameter. Future sections show howmaincan take parameters to receive command line arguments.
Statements:
-
In Python, each statement is on a separate line.
-
In C, each statement ends with a semicolon
;. In C, statements must be within the body of some function (inmainin this example).
Output:
-
In Python, the
printfunction prints a formatted string. Values for the placeholders in the format string follow a%symbol in a comma-separated list of values (for example, the value ofsqrt(4)will be printed in place of the%fplaceholder in the format string). -
In C, the
printffunction prints a formatted string. Values for the placeholders in the format string are additional arguments separated by commas (for example, the value ofsqrt(4)will be printed in place of the%fplaceholder in the format string).
There are a few important differences to note in the C and Python versions of this program:
Indentation: In C, indentation doesn’t have meaning, but it’s good programming style to indent statements based on the nested level of their containing block.
Output: C’s printf function doesn’t automatically print a newline character
at the end like Python’s print function does. As a result, C programmers
need to explicitly specify a newline character (\n) in the format string when
a newline is desired in the output.
main function:
-
A C program must have a function named
main, and its return type must beint. This means that themainfunction returns a signed integer type value. Python programs don’t need to name their main functionmain, but they often do by convention. -
The C
mainfunction has an explicitreturnstatement to return anintvalue (by convention,mainshould return0if the main function is successfully executed without errors). -
A Python program needs to include an explicit call to its
mainfunction to run it when the program executes. In C, itsmainfunction is automatically called when the C program executes.
1.1.1. Compiling and Running C Programs
Python is an interpreted programming language, which means that another
program, the Python interpreter, runs Python programs: the Python interpreter
acts like a virtual machine on which Python programs are run. To run a Python
program, the program source code (hello.py) is given as input to the Python
interpreter program that runs it. For example ($ is the Linux shell prompt):
$ python hello.py
The Python interpreter is a program that is in a form that can be run directly on the underlying system (this form is called binary executable) and takes as input the Python program that it runs (Figure 4).
To run a C program, it must first be translated into a form that a computer system can directly execute. A C compiler is a program that translates C source code into a binary executable form that the computer hardware can directly execute. A binary executable consists of a series of 0’s and 1’s in a well-defined format that a computer can run.
For example, to run the C program hello.c on a Unix system, the C code must
first be compiled by a C compiler (for example, the GNU C
compiler, GCC) that produces a binary executable (by default named a.out).
The binary executable version of the program can then be run directly on the
system (Figure 5):
$ gcc hello.c
$ ./a.out
(Note that some C compilers might need to be explicitly told to link in the math
library: -lm):
$ gcc hello.c -lm
Detailed Steps
In general, the following sequence describes the necessary steps for editing, compiling, and running a C program on a Unix system:
-
Using a text editor (for example,
vim), write and save your C source code program in a file (e.g.,hello.c):$ vim hello.c
-
Compile the source to an executable form, and then run it. The most basic syntax for compiling with
gccis:$ gcc <input_source_file>
If compilation yields no errors, the compiler creates a binary executable file named a.out. The compiler also allows you to specify the name of the binary executable file to generate using the -o flag:
$ gcc -o <output_executable_file> <input_source_file>
For example, this command instructs gcc to compile hello.c into an
executable file named hello:
$ gcc -o hello hello.c
We can invoke the executable program using ./hello:
$ ./hello
Any changes made to the C source code (the hello.c file) must be recompiled
with gcc to produce a new version of hello. If the compiler detects any
errors during compilation, the ./hello file won’t be created/re-created (but
beware, an older version of the file from a previous successful compilation might
still exist).
Often when compiling with gcc, you want to include several command line
options. For example, these options enable more compiler warnings and build a
binary executable with extra debugging information:
$ gcc -Wall -g -o hello hello.c
Because the gcc command line can be long, frequently the make utility is
used to simplify compiling C programs and for cleaning up files created by
gcc.
Using make
and writing Makefiles are important skills that you will develop as you build
up experience with C programming.
We cover compiling and linking with C library code in more detail at the end of Chapter 2.
1.1.2. Variables and C Numeric Types
Like Python, C uses variables as named storage locations for holding data. Thinking about the scope and type of program variables is important to understand the semantics of what your program will do when you run it. A variable’s scope defines when the variable has meaning (that is, where and when in your program it can be used) and its lifetime (that is, it could persist for the entire run of a program or only during a function activation). A variable’s type defines the range of values that it can represent and how those values will be interpreted when performing operations on its data.
In C, all variables must be declared before they can be used. To declare a variable, use the following syntax:
type_name variable_name;
A variable can have only a single type. The basic C types include char,
int, float, and double. By convention, C variables should be declared at
the beginning of their scope (at the top of a { } block), before any C
statements in that scope.
Below is an example C code snippet that shows declarations and uses of variables of some different types. We discuss types and operators in more detail after the example.
{
/* 1. Define variables in this block's scope at the top of the block. */
int x; // declares x to be an int type variable and allocates space for it
int i, j, k; // can define multiple variables of the same type like this
char letter; // a char stores a single-byte integer value
// it is often used to store a single ASCII character
// value (the ASCII numeric encoding of a character)
// a char in C is a different type than a string in C
float winpct; // winpct is declared to be a float type
double pi; // the double type is more precise than float
/* 2. After defining all variables, you can use them in C statements. */
x = 7; // x stores 7 (initialize variables before using their value)
k = x + 2; // use x's value in an expression
letter = 'A'; // a single quote is used for single character value
letter = letter + 1; // letter stores 'B' (ASCII value one more than 'A')
pi = 3.1415926;
winpct = 11 / 2.0; // winpct gets 5.5, winpct is a float type
j = 11 / 2; // j gets 5: int division truncates after the decimal
x = k % 2; // % is C's mod operator, so x gets 9 mod 2 (1)
}
Note the semicolons galore. Recall that C statements are delineated by ;,
not line breaks — C expects a semicolon after every statement. You’ll forget
some, and gcc almost never informs you that you missed a semicolon, even
though that might be the only syntax error in your program. In fact, often
when you forget a semicolon, the compiler indicates a syntax error on the
line after the one with the missing semicolon: the reason is that gcc
interprets it as part of the statement from the previous line. As you continue
to program in C, you’ll learn to correlate gcc errors with the specific C
syntax mistakes that they describe.
1.1.3. C Types
C supports a small set of built-in data types, and it provides a few ways in which programmers can construct basic collections of types (arrays and structs). From these basic building blocks, a C programmer can build complex data structures.
C defines a set of basic types for storing numeric values. Here are some examples of numeric literal values of different C types:
8 // the int value 8
3.4 // the double value 3.4
'h' // the char value 'h' (its value is 104, the ASCII value of h)
The C char type stores a numeric value. However, it’s often used by
programmers to store the value of an ASCII character. A character literal
value is specified in C as a single character between single quotes.
C doesn’t support a string type, but programmers can create strings from the
char type and C’s support for constructing arrays of values, which we discuss
in later sections. C does, however, support a way of expressing string literal
values in programs: a string literal is any sequence of characters between
double quotes. C programmers often pass string literals as the format string
argument to printf:
printf("this is a C string\n");
Python supports strings, but it doesn’t have a char type. In C, a string and
a char are two very different types, and they evaluate differently. This
difference is illustrated by contrasting a C string literal that contains one
character with a C char literal. For example:
'h' // this is a char literal value (its value is 104, the ASCII value of h)
"h" // this is a string literal value (its value is NOT 104, it is not a char)
We discuss C strings and char variables in more detail in the
Strings section later in
this chapter. Here, we’ll mainly focus on C’s numeric types.
C Numeric Types
C supports several different types for storing numeric values. The types
differ in the format of the numeric values they represent. For example, the
float and double types can represent real values, int represents signed
integer values, and unsigned int represents unsigned integer values. Real
values are positive or negative values with a decimal point, such as -1.23 or
0.0056. Signed integers store positive, negative, or zero integer values,
such as -333, 0, or 3456. Unsigned integers store strictly nonnegative
integer values, such as 0 or 1234.
C’s numeric types also differ in the range and precision of the values they can represent. The range or precision of a value depends on the number of bytes associated with its type. Types with more bytes can represent a larger range of values (for integer types), or higher-precision values (for real types), than types with fewer bytes.
Table 2 shows the number of storage bytes, the kind of numeric values stored, and how to declare a variable for a variety of common C numeric types (note that these are typical sizes — the exact number of bytes depends on the hardware architecture).
| Type name | Usual size | Values stored | How to declare |
|---|---|---|---|
|
1 byte |
integers |
|
|
2 bytes |
signed integers |
|
|
4 bytes |
signed integers |
|
|
4 or 8 bytes |
signed integers |
|
|
8 bytes |
signed integers |
|
|
4 bytes |
signed real numbers |
|
|
8 bytes |
signed real numbers |
|
C also provides unsigned versions of the integer numeric types (char,
short, int, long, and long long). To declare a variable as unsigned,
add the keyword unsigned before the type name. For example:
int x; // x is a signed int variable
unsigned int y; // y is an unsigned int variable
The C standard doesn’t specify whether the char type is signed or unsigned.
As a result, some implementations might implement char as signed integer values
and others as unsigned. It’s good programming practice to explicitly declare
unsigned char if you want to use the unsigned version of a char variable.
The exact number of bytes for each of the C types might vary from one
architecture to the next. The sizes in Table 2 are minimum (and
common) sizes for each type. You can print the exact size on a given machine
using C’s sizeof operator, which takes the name of a type as an argument and
evaluates to the number of bytes used to store that type. For example:
printf("number of bytes in an int: %lu\n", sizeof(int));
printf("number of bytes in a short: %lu\n", sizeof(short));
The sizeof operator evaluates to an unsigned long value, so in the call to
printf, use the placeholder %lu to print its value. On most architectures
the output of these statements will be:
number of bytes in an int: 4 number of bytes in a short: 2
Arithmetic Operators
Arithmetic operators combine values of numeric types. The resulting type of
the operation is based on the types of the operands. For example, if two int
values are combined with an arithmetic operator, the resulting type is also an
integer.
C performs automatic type conversion when an operator combines operands of
two different types. For example, if an int operand is combined with
a float operand, the integer operand is first converted to its floating-point
equivalent before the operator is applied, and the type of the operation’s result
is float.
The following arithmetic operators can be used on most numeric type operands:
-
add (
+) and subtract (-) -
multiply (
*), divide (/), and mod (%):The mod operator (
%) can only take integer-type operands (int,unsigned int,short, and so on).If both operands are
inttypes, the divide operator (/) performs integer division (the resulting value is anint, truncating anything beyond the decimal point from the division operation). For example8/3evaluates to2.If one or both of the operands are
float(ordouble),/performs real division and evaluates to afloat(ordouble) result. For example,8 / 3.0evaluates to approximately2.666667. -
assignment (
=):variable = value of expression; // e.g., x = 3 + 4;
-
assignment with update (
+=,-=,*=,/=, and%=):variable op= expression; // e.g., x += 3; is shorthand for x = x + 3;
-
increment (
++) and decrement (--):variable++; // e.g., x++; assigns to x the value of x + 1
|
Pre- vs. Post-increment
The operators
In many cases, it doesn’t matter which you use because the value of the incremented or decremented variable isn’t being used in the statement. For example, these two statements are equivalent (although the first is the most commonly used syntax for this statement):
In some cases, the context affects the outcome (when the value of the incremented or decremented variable is being used in the statement). For example:
Code like the preceding example that uses an arithmetic expression with an
increment operator is often hard to read, and it’s easy to get wrong. As a
result, it’s generally best to avoid writing code like this; instead, write
separate statements for exactly the order you want. For example, if you want
to first increment Instead of writing this:
write it as two separate statements:
|
1.2. Input/Output (printf and scanf)
C’s printf function prints values to the terminal, and the scanf function
reads in values entered by a user. The printf and scanf functions belong
to C’s standard I/O library, which needs to be explicitly included at the top
of any .c file that uses these functions by using #include <stdio.h>. In
this section, we introduce the basics of using printf and scanf in C
programs. The "I/O" section in Chapter 2 discusses
C’s input and output functions in more detail.
1.2.1. printf
C’s printf function is very similar to formatted print in Python, where the
caller specifies a format string to print. The format string often contains
formatting specifiers, such as special characters that will print tabs (\t)
or newlines (\n), or placeholders for values in the output. Placeholders
consist of % followed by a type specifier letter (for example, %d
represents a placeholder for an integer value). For each placeholder in the
format string, printf expects an additional argument. Table 3
contains an example program in Python and C with formatted output:
| Python version | C version |
|---|---|
|
|
When run, both versions of this program produce identically formatted output:
Name: Vijay, Info: Age: 20 Ht: 5.9 Year: 3 Dorm: Alice Paul
The main difference between C’s printf and Python’s print functions are
that the Python version implicitly prints a newline character at the end of the
output string, but the C version does not. As a result, the C format strings in
this example have newline (\n) characters at the end to explicitly print a
newline character. The syntax for listing the argument values for the
placeholders in the format string is also slightly different in C’s printf
and Python’s print functions.
C uses the same formatting placeholders as Python for specifying different types of values. The preceding example demonstrates the following formatting placeholders:
%g: placeholder for a float (or double) value %d: placeholder for a decimal value (int, short, char) %s: placeholder for a string value
C additionally supports the %c placeholder for printing a character value.
This placeholder is useful when a programmer wants to print the ASCII character
associated with a particular numeric encoding. Here’s a C code snippet that
prints a char as its numeric value (%d) and as its character encoding
(%c):
// Example printing a char value as its decimal representation (%d)
// and as the ASCII character that its value encodes (%c)
char ch;
ch = 'A';
printf("ch value is %d which is the ASCII value of %c\n", ch, ch);
ch = 99;
printf("ch value is %d which is the ASCII value of %c\n", ch, ch);
When run, the program’s output looks like this:
ch value is 65 which is the ASCII value of A ch value is 99 which is the ASCII value of c
1.2.2. scanf
C’s scanf function represents one method for reading in values entered by the
user (via the keyboard) and storing them in program variables. The scanf
function can be a bit picky about the exact format in which the user enters
data, which means that it’s not very robust to badly formed user input. In the
"I/O" section in Chapter 2, we discuss more robust
ways of reading input values from the user. For now, remember that if your
program gets into an infinite loop due to badly formed user input, you can
always press CTRL-C to terminate it.
Reading input is handled differently in Python and C: Python uses the input
function to read in a value as a string, and then the program converts the
string value to an int, whereas C uses scanf to read in an int value and to
store it at the location in memory of an int program variable (for example,
&num1). Table 4 displays example programs for reading user
input values in Python and C:
| Python version | C version |
|---|---|
|
|
When run, both programs read in two values (here, 30 and 67):
Enter a number: 30 Enter another: 67 30 + 67 = 97
Like printf, scanf takes a format string that specifies the number and types
of values to read in (for example, "%d" specifies one int value). The
scanf function skips over leading and trailing whitespace as it reads in a
numeric value, so its format string only needs to contain a sequence of
formatting placeholders, usually with no whitespace or other formatting
characters between the placeholders in its format string. The arguments for
the placeholders in the format string specify the locations of program
variables into which the values read in will be stored. Prefixing the name of
a variable with the & operator produces the location of that variable in the
program’s memory — the memory address of the variable. The
"Pointers" section in Chapter 2
discusses the & operator in more detail. For now, we use it only in the
context of the scanf function.
Here’s another scanf example, in which the format string has placeholders
for two values, the first an int and the second a float:
int x;
float pi;
// read in an int value followed by a float value ("%d%g")
// store the int value at the memory location of x (&x)
// store the float value at the memory location of pi (&pi)
scanf("%d%g", &x, &pi);
When inputting data to a program via scanf, individual numeric input values
must be separated by at least one whitespace character. However, because
scanf skips over additional leading and trailing whitespace characters (for
example, spaces, tabs, and newlines), a user could enter input values with any
amount of space before or after each input value. For instance, if a user
enters the following for the call to scanf in the preceding example, scanf
will read in 8 and store it in the x variable, and then read in 3.14 and
store it in the pi variable:
8 3.14
1.3. Conditionals and Loops
Table 5 shows that the syntax and semantics of if-else
statements in C and Python are very similar. The main syntactic difference is
that Python uses indentation to indicate "body" statements, whereas C uses
curly braces (but you should still use good indentation in your C code).
| Python version | C version |
|---|---|
|
|
The Python and C syntax for if-else statements is almost identical with
only minor differences. In both, the else part is optional. Python and C
also support multiway branching by chaining if and else if statements. The
following describes the full if-else C syntax:
// a one-way branch:
if ( <boolean expression> ) {
<true body>
}
// a two-way branch:
if ( <boolean expression> ) {
<true body>
}
else {
<false body>
}
// a multibranch (chaining if-else if-...-else)
// (has one or more 'else if' following the first if):
if ( <boolean expression 1> ) {
<true body>
}
else if ( <boolean expression 2> ) {
// first expression is false, second is true
<true 2 body>
}
else if ( <boolean expression 3> ) {
// first and second expressions are false, third is true
<true 3 body>
}
// ... more else if's ...
else if ( <boolean expression N> ) {
// first N-1 expressions are false, Nth is true
<true N body>
}
else { // the final else part is optional
// if all previous expressions are false
<false body>
}
1.3.1. Boolean Values in C
C doesn’t provide a Boolean type with true or false values. Instead, integer values evaluate to true or false when used in conditional statements. When used in conditional expressions, any integer expression that is:
-
zero (0) evaluates to false
-
nonzero (any positive or negative value) evaluates to true
C has a set of relational and logical operators for Boolean expressions.
The relational operators take operand(s) of the same type and evaluate to zero (false) or nonzero (true). The set of relational operators are:
-
equality (
==) and inequality (not equal,!=) -
comparison operators: less than (
<), less than or equal (<=), greater than (>), and greater than or equal (>=)
Here are some C code snippets showing examples of relational operators:
// assume x and y are ints, and have been assigned
// values before this point in the code
if (y < 0) {
printf("y is negative\n");
} else if (y != 0) {
printf("y is positive\n");
} else {
printf("y is zero\n");
}
// set x and y to the larger of the two values
if (x >= y) {
y = x;
} else {
x = y;
}
C’s logical operators take integer "Boolean" operand(s) and evaluate to either zero (false) or nonzero (true). The set of logical operators are:
-
logical negation (
!) -
logical and (
&&): stops evaluating at the first false expression (short-circuiting) -
logical or (
||): stops evaluating at the first true expression (short-circuiting)
C’s short-circuit logical operator evaluation stops evaluating a logical
expression as soon as the result is known. For example, if the first operand
to a logical and (&&) expression evaluates to false, the result of the &&
expression must be false. As a result, the second operand’s value need not be
evaluated, and it is not evaluated.
The following is an example of conditional statements in C that use logical operators (it’s always best to use parentheses around complex Boolean expressions to make them easier to read):
if ( (x > 10) && (y >= x) ) {
printf("y and x are both larger than 10\n");
x = 13;
} else if ( ((-x) == 10) || (y > x) ) {
printf("y might be bigger than x\n");
x = y * x;
} else {
printf("I have no idea what the relationship between x and y is\n");
}
1.3.2. Loops in C
Like Python, C supports for and while loops. Additionally, C provides
do-while loops.
while Loops
The while loop syntax in C and Python is almost identical, and the behavior
is the same. Table 6 shows example programs of while loops
in C and Python.
| Python version | C version |
|---|---|
|
|
The while loop syntax in C is very similar in Python, and both are evaluated
in the same way:
while ( <boolean expression> ) {
<true body>
}
The while loop checks the Boolean expression first and executes the body if
true. In the preceding example program, the value of the val variable will be
repeatedly printed in the while loop until its value is greater than the
value of the num variable. If the user enters 10, the C and Python
programs will print:
1 2 4 8
C also has a do-while loop that is similar to its while loop, but
it executes the loop body first and then checks a condition and repeats
executing the loop body for as long as the condition is true. That is,
a do-while loop will always execute the loop body at least one time:
do {
<body>
} while ( <boolean expression> );
For additional while loop examples, try these two programs:
for Loops
The for loop is different in C than it is in Python. In Python, for loops
are iterations over sequences, whereas in C, for loops are more general
looping constructs. Table 7 shows example programs that use for
loops to print all the values between 0 and a user-provided input number:
| Python version | C version |
|---|---|
|
|
In this example, you can see that the C for loop syntax is quite different
from the Python for loop syntax. It’s also evaluated differently.
The C for loop syntax is:
for ( <initialization>; <boolean expression>; <step> ) {
<body>
}
The for loop evaluation rules are:
-
Evaluate initialization one time when first entering the loop.
-
Evaluate the boolean expression. If it’s 0 (false), drop out of the
forloop (that is, the program is done repeating the loop body statements). -
Evaluate the statements inside the loop body.
-
Evaluate the step expression.
-
Repeat from step (2).
Here’s a simple example for loop to print the values 0, 1, and 2:
int i;
for (i = 0; i < 3; i++) {
printf("%d\n", i);
}
Executing the for loop evaluation rules on the preceding loop yields the
following sequence of actions:
(1) eval init: i is set to 0 (i=0) (2) eval bool expr: i < 3 is true (3) execute loop body: print the value of i (0) (4) eval step: i is set to 1 (i++) (2) eval bool expr: i < 3 is true (3) execute loop body: print the value of i (1) (4) eval step: i is set to 2 (i++) (2) eval bool expr: i < 3 is true (3) execute loop body: print the value of i (2) (4) eval step: i is set to 3 (i++) (2) eval bool expr: i < 3 is false, drop out of the for loop
The following program shows a more complicated for loop example (it’s also
available to download). Note that just
because C supports for loops with a list of statements for its
initialization and step parts, it’s best to keep it simple (this example
illustrates a more complicated for loop syntax, but the for loop would be
easier to read and understand if it were simplified by moving the j += 10 step
statement to the end of the loop body and having just a single step statement,
i += 1).
/* An example of a more complex for loop which uses multiple variables.
* (it is unusual to have for loops with multiple statements in the
* init and step parts, but C supports it and there are times when it
* is useful...don't go nuts with this just because you can)
*/
#include <stdio.h>
int main(void) {
int i, j;
for (i=0, j=0; i < 10; i+=1, j+=10) {
printf("i+j = %d\n", i+j);
}
return 0;
}
// the rules for evaluating a for loop are the same no matter how
// simple or complex each part is:
// (1) evaluate the initialization statements once on the first
// evaluation of the for loop: i=0 and j=0
// (2) evaluate the boolean condition: i < 10
// if false (when i is 10), drop out of the for loop
// (3) execute the statements inside the for loop body: printf
// (4) evaluate the step statements: i += 1, j += 10
// (5) repeat, starting at step (2)
In C, for loops and while loops are equivalent in power, meaning that any
while loop can be expressed as a for loop, and vice versa. The same is not true
in Python, where for loops are iterations over a sequence of values. As
such, they cannot express some looping behavior that the more general Python
while loop can express. Indefinite loops are one example that can only be
written as a while loop in Python.
Consider the following while loop in C:
int guess = 0;
while (guess != num) {
printf("%d is not the right number\n", guess);
printf("Enter another guess: ");
scanf("%d", &guess);
}
This loop can be translated to an equivalent for loop in C:
int guess;
for (guess = 0; guess != num; ) {
printf("%d is not the right number\n", guess);
printf("Enter another guess: ");
scanf("%d", &guess);
}
In Python, however, this type of looping behavior can be expressed only by
using a while loop.
Because for and while loops are equally expressive in C, only one looping
construct is needed in the language. However, for loops are a more natural
language construct for definite loops (like iterating over a range of values),
whereas while loops are a more natural language construct for indefinite loops
(like repeating until the user enters an even number). As a result, C provides
both to programmers.
1.4. Functions
Functions break code into manageable pieces and reduce code duplication. Functions might take zero or more parameters as input and they return a single value of a specific type. A function declaration or prototype specifies the function’s name, its return type, and its parameter list (the number and types of all the parameters). A function definition includes the code to be executed when the function is called. All functions in C must be declared before they’re called. This can be done by declaring a function prototype or by fully defining the function before calling it:
// function definition format:
// ---------------------------
<return type> <function name> (<parameter list>)
{
<function body>
}
// parameter list format:
// ---------------------
<type> <param1 name>, <type> <param2 name>, ..., <type> <last param name>
Here’s an example function definition. Note that the comments describe what the function does, the details of each parameter (what it’s used for and what it should be passed), and what the function returns:
/* This program computes the larger of two
* values entered by the user.
*/
#include <stdio.h>
/* max: computes the larger of two integer values
* x: one integer value
* y: the other integer value
* returns: the larger of x and y
*/
int max(int x, int y) {
int bigger;
bigger = x;
if (y > x) {
bigger = y;
}
printf(" in max, before return x: %d y: %d\n", x, y);
return bigger;
}
Functions that don’t return a value should specify the void return type.
Here’s an example of a void function:
/* prints out the squares from start to stop
* start: the beginning of the range
* stop: the end of the range
*/
void print_table(int start, int stop) {
int i;
for (i = start; i <= stop; i++) {
printf("%d\t", i*i);
}
printf("\n");
}
As in any programming language that supports functions or procedures, a function call invokes a function, passing specific argument values for the particular call. A function is called by its name and is passed arguments, with one argument for each corresponding function parameter. In C, calling a function looks like this:
// function call format:
// ---------------------
function_name(<argument list>);
// argument list format:
// ---------------------
<argument 1 expression>, <argument 2 expression>, ..., <last argument expression>
Arguments to C functions are passed by value: each function parameter is assigned the value of the corresponding argument passed to it in the function call by the caller. Pass by value semantics mean that any change to a parameter’s value in the function (that is, assigning a parameter a new value in the function) is not visible to the caller.
Here are some example function calls to the max and print_table functions
listed earlier:
int val1, val2, result;
val1 = 6;
val2 = 10;
/* to call max, pass in two int values, and because max returns an
int value, assign its return value to a local variable (result)
*/
result = max(val1, val2); /* call max with argument values 6 and 10 */
printf("%d\n", result); /* prints out 10 */
result = max(11, 3); /* call max with argument values 11 and 3 */
printf("%d\n", result); /* prints out 11 */
result = max(val1 * 2, val2); /* call max with argument values 12 and 10 */
printf("%d\n", result); /* prints out 12 */
/* print_table does not return a value, but takes two arguments */
print_table(1, 20); /* prints a table of values from 1 to 20 */
print_table(val1, val2); /* prints a table of values from 6 to 10 */
Here is another example of a full program that shows a call to a
slightly different implementation of the max function that has an
additional statement to change the value of its parameter (x = y):
/* max: computes the larger of two int values
* x: one value
* y: the other value
* returns: the larger of x and y
*/
int max(int x, int y) {
int bigger;
bigger = x;
if (y > x) {
bigger = y;
// note: changing the parameter x's value here will not
// change the value of its corresponding argument
x = y;
}
printf(" in max, before return x: %d y: %d\n", x, y);
return bigger;
}
/* main: shows a call to max */
int main(void) {
int a, b, res;
printf("Enter two integer values: ");
scanf("%d%d", &a, &b);
res = max(a, b);
printf("The larger value of %d and %d is %d\n", a, b, res);
return 0;
}
The following output shows what two runs of this program might look like. Note
the difference in the parameter x’s value (printed from inside the `max
function) in the two runs. Specifically, notice that changing the value of
parameter x in the second run does not affect the variable that was passed
in as an argument to max after the call returns:
$ ./a.out Enter two integer values: 11 7 in max, before return x: 11 y: 7 The larger value of 11 and 7 is 11 $ ./a.out Enter two integer values: 13 100 in max, before return x: 100 y: 100 The larger value of 13 and 100 is 100
Because arguments are passed by value to functions, the preceding version of
the max function that changes one of its parameter values behaves identically
to the original version of max that does not.
1.4.1. The Stack
The execution stack keeps track of the state of active functions in a program. Each function call creates a new stack frame (sometimes called an activation frame or activation record) containing its parameter and local variable values. The frame on the top of the stack is the active frame; it represents the function activation that is currently executing, and only its local variables and parameters are in scope. When a function is called, a new stack frame is created for it (pushed on the top of the stack), and space for its local variables and parameters is allocated in the new frame. When a function returns, its stack frame is removed from the stack (popped from the top of the stack), leaving the caller’s stack frame on the top of the stack.
For the example preceding program, at the point in its execution right before max
executes the return statement, the execution stack will look like
Figure 6. Recall that the argument values to max passed by
main are passed by value, meaning that the parameters to max, x and
y, are assigned the values of their corresponding arguments, a and b from
the call in main. Despite the max function changing the value of x, the
change doesn’t affect the value of a in main.
The following full program includes two functions and shows examples of calling
them from the main function. In this program, we declare function prototypes
for max and print_table above the main function so that main can access them
despite being defined first. The main function contains the high-level steps
of the full program, and defining it first echoes the top-down design of the
program. This example includes comments describing the parts of the program
that are important to functions and function calls. You can also download and
run the full program.
/* This file shows examples of defining and calling C functions.
* It also demonstrates using scanf().
*/
#include <stdio.h>
/* This is an example of a FUNCTION PROTOTYPE. It declares just the type
* information for a function (the function's name, return type, and parameter
* list). A prototype is used when code in main wants to call the function
* before its full definition appears in the file.
*/
int max(int n1, int n2);
/* A prototype for another function. void is the return type of a function
* that does not return a value
*/
void print_table(int start, int stop);
/* All C programs must have a main function. This function defines what the
* program does when it begins executing, and it's typically used to organize
* the big-picture behavior of the program.
*/
int main(void) {
int x, y, larger;
printf("This program will operate over two int values.\n");
printf("Enter the first value: ");
scanf("%d", &x);
printf("Enter the second value: ");
scanf("%d", &y);
larger = max(x, y);
printf("The larger of %d and %d is %d\n", x, y, larger);
print_table(x, larger);
return 0;
}
/* This is an example of a FUNCTION DEFINITION. It specifies not only the
* function name and type, but it also fully defines the code of its body.
* (Notice, and emulate, the complete function comment!)
*/
/* Computes the max of two integer values.
* n1: the first value
* n2: the other value
* returns: the larger of n1 and n2
*/
int max(int n1, int n2) {
int result;
result = n1;
if (n2 > n1) {
result = n2;
}
return result;
}
/* prints out the squares from start to stop
* start: the beginning of the range
* stop: the end of the range
*/
void print_table(int start, int stop) {
int i;
for (i = start; i <= stop; i++) {
printf("%d\t", i*i);
}
printf("\n");
}
1.5. Arrays and Strings
An array is a C construct that creates an ordered collection of data elements of the same type and associates this collection with a single program variable. Ordered means that each element is in a specific position in the collection of values (that is, there is an element in position 0, position 1, and so on), not that the values are necessarily sorted. Arrays are one of C’s primary mechanisms for grouping multiple data values and referring to them by a single name. Arrays come in several flavors, but the basic form is a one-dimensional array, which is useful for implementing list-like data structures and strings in C.
1.5.1. Introduction to Arrays
C arrays can store multiple data values of the same type. In this chapter, we discuss statically declared arrays, meaning that the total capacity (the maximum number of elements that can be stored in an array) is fixed and is defined when the array variable is declared. In the next chapter, we discuss dynamically allocated arrays and multi-dimensional arrays.
Table 8 shows Python and C versions of a program that
initializes and then prints a collection of integer values. The Python
version uses its built-in list type to store the list of values, whereas the C
version uses an array of int types to store the collection of values.
In general, Python provides a high-level list interface to the programmer that
hides much of the low-level implementation details. C, on the other hand,
exposes a low-level array implementation to the programmer and leaves it up to
the programmer to implement higher-level functionality. In other words, arrays
enable low-level data storage without higher-level list functionality, such as
len, append, insert, and so on.
| Python version | C version |
|---|---|
|
|
The C and Python versions of this program have several similarities,
most notably that individual elements can be accessed via indexing,
and that index values start at 0. That is, both languages refer to the
very first element in a collection as the element at position 0.
The main differences in the C and Python versions of this program relate to the capacity of the list or array and how their sizes (number of elements) are determined.
my_lst[3] = 100 # Python syntax to set the element in position 3 to 100.
my_lst[0] = 5 # Python syntax to set the first element to 5.
my_arr[3] = 100; // C syntax to set the element in position 3 to 100.
my_arr[0] = 5; // C syntax to set the first element to 5.
In the Python version, the programmer doesn’t need to specify the capacity of
a list in advance: Python automatically increases a list’s capacity as
needed by the program. For example, the Python append function automatically
increases the size of the Python list and adds the passed value to the end.
In contrast, when declaring an array variable in C, the programmer must specify its type (the type of each value stored in the array) and its total capacity (the maximum number of storage locations). For example:
int arr[10]; // declare an array of 10 ints
char str[20]; // declare an array of 20 chars
The preceding declarations create one variable named arr, an array of int
values with a total capacity of 10, and another variable named str, an array
of char values with a total capacity of 20.
To compute the size of a list (size meaning the total number of values in the
list), Python provides a len function that returns the size of any list
passed to it. In C, the programmer has to explicitly keep track of the number
of elements in the array (for example, the size variable in
Table 8).
Another difference that might not be apparent from looking at the Python and C versions of this program is how the Python list and the C array are stored in memory. C dictates the array layout in program memory, whereas Python hides how lists are implemented from the programmer. In C, individual array elements are allocated in consecutive locations in the program’s memory. For example, the third array position is located in memory immediately following the second array position and immediately before the fourth array position.
1.5.2. Array Access Methods
Python provides multiple ways to access elements in its lists. C, however, supports only indexing, as described earlier. Valid index values range from 0 to the capacity of the array minus 1. Here are some examples:
int i, num;
int arr[10]; // declare an array of ints, with a capacity of 10
num = 6; // keep track of how many elements of arr are used
// initialize first 5 elements of arr (at indices 0-4)
for (i=0; i < 5; i++) {
arr[i] = i * 2;
}
arr[5] = 100; // assign the element at index 5 the value 100
This example declares the array with a capacity of 10 (it has 10 elements), but
it only uses the first six (our current collection of values is size 6, not 10).
It’s often the case when using statically declared arrays that some of an
array’s capacity will remain unused. As a result, we need another program
variable to keep track of the actual size (number of elements) in the array
(num in this example).
Python and C differ in their error-handling approaches when a program attempts
to access an invalid index. Python throws an IndexError exception if an
invalid index value is used to access elements in a list (for example, indexing
beyond the number of elements in a list). In C, it’s up to the programmer to
ensure that their code uses only valid index values when indexing into arrays. As a
result, for code like the following that accesses an array element beyond the
bounds of the allocated array, the program’s runtime behavior is undefined:
int array[10]; // an array of size 10 has valid indices 0 through 9
array[10] = 100; // 10 is not a valid index into the array
The C compiler is happy to compile code that accesses array positions beyond the bounds of the array; there is no bounds checking by the compiler or at runtime. As a result, running this code can lead to unexpected program behavior (and the behavior might differ from run to run). It can lead to your program crashing, it can change another variable’s value, or it might have no effect on your program’s behavior. In other words, this situation leads to a program bug that might or might not show up as unexpected program behavior. Thus, as a C programmer, it’s up to you to ensure that your array accesses refer to valid positions!
1.5.3. Arrays and Functions
The semantics of passing arrays to functions in C is similar to that of passing
lists to functions in Python: the function can alter the elements in the passed
array or list. Here’s an example function that takes two parameters, an int
array parameter (arr), and an int parameter (size):
void print_array(int arr[], int size) {
int i;
for (i = 0; i < size; i++) {
printf("%d\n", arr[i]);
}
}
The [] after the parameter name tells the compiler that the type of the
parameter arr is array of int, not int like the parameter size. In the
next chapter, we show an alternate syntax for specifying array parameters. The
capacity of the array parameter arr isn’t specified: arr[] means that
this function can be called with an array argument of any capacity. Because
there is no way to get an array’s size or capacity just from the array
variable, functions that are passed arrays almost always also have a second
parameter that specifies the array’s size (the size parameter in the
preceding example).
To call a function that has an array parameter, pass the name of the array as
the argument. Here is a C code snippet with example calls to the print_array
function:
int some[5], more[10], i;
for (i = 0; i < 5; i++) { // initialize the first 5 elements of both arrays
some[i] = i * i;
more[i] = some[i];
}
for (i = 5; i < 10; i++) { // initialize the last 5 elements of "more" array
more[i] = more[i-1] + more[i-2];
}
print_array(some, 5); // prints all 5 values of "some"
print_array(more, 10); // prints all 10 values of "more"
print_array(more, 8); // prints just the first 8 values of "more"
In C, the name of the array variable is equivalent to the base address of the array (that is, the memory location of its 0th element). Due to C’s pass by value function call semantics, when you pass an array to a function, each element of the array is not individually passed to the function. In other words, the function isn’t receiving a copy of each array element. Instead, an array parameter gets the value of the array’s base address. This behavior implies that when a function modifies the elements of an array that was passed as a parameter, the changes will persist when the function returns. For example, consider this C program snippet:
void test(int a[], int size) {
if (size > 3) {
a[3] = 8;
}
size = 2; // changing parameter does NOT change argument
}
int main(void) {
int arr[5], n = 5, i;
for (i = 0; i < n; i++) {
arr[i] = i;
}
printf("%d %d", arr[3], n); // prints: 3 5
test(arr, n);
printf("%d %d", arr[3], n); // prints: 8 5
return 0;
}
The call in main to the test function is passed the argument arr, whose
value is the base address of the arr array in memory. The parameter a in
the test function gets a copy of this base address value. In other words,
parameter a refers to the same array storage locations as its argument,
arr. As a result, when the test function changes a value stored in the a
array (a[3] = 8), it affects the corresponding position in the argument array
(arr[3] is now 8). The reason is that the value of a is the base address
of arr, and the value of arr is the base address of arr, so both a and
arr refer to the same array (the same storage locations in memory)!
Figure 7 shows the stack contents at the point in the execution just
before the test function returns.
Parameter a is passed the value of the base address of the array argument
arr, which means they both refer to the same set of array storage locations
in memory. We indicate this with the arrow from a to arr. Values that get
modified by the function test are highlighted. Changing the value of the
parameter size does not change the value of its corresponding argument n,
but changing the value of one of the elements referred to by a (for example,
a[3] = 8) does affect the value of the corresponding position in arr.
1.5.4. Introduction to Strings and the C String Library
Python implements a string type and provides a rich interface for using
strings, but there is no corresponding string type in C. Instead, strings are
implemented as arrays of char values. Not every character array is used as a
C string, but every C string is a character array.
Recall that arrays in C might be defined with a larger size than a program
ultimately uses. For example, we saw earlier in the section
"Array Access Methods" that
we might declare an array of size 10 but only use the first six positions. This
behavior has important implications for strings: we can’t assume that a
string’s length is equal to that of the array that stores it. For this reason,
strings in C must end with a special character value, the null character
('\0'), to indicate the end of the string.
Strings that end with a null character are said to be null-terminated.
Although all strings in C should be null-terminated, failing to properly
account for null characters is a common source of errors for novice C
programmers. When using strings, it’s important to keep in mind that your
character arrays must be declared with enough capacity to store each character
value in the string plus the null character ('\0'). For example, to
store the string "hi", you need an array of at least three chars (one to store
'h', one to store 'i', and one to store '\0').
Because strings are commonly used, C provides a string library that contains
functions for manipulating strings. Programs that use these string library
functions need to include the string.h header.
When printing the value of a string with printf, use the %s placeholder in
the format string. The printf function will print all the characters in the
array argument until it encounters the '\0' character. Similarly,
string library functions often either locate the end of a string by searching
for the '\0' character or add a '\0' character to the end of
any string that they modify.
Here’s an example program that uses strings and string library functions:
#include <stdio.h>
#include <string.h> // include the C string library
int main(void) {
char str1[10];
char str2[10];
int len;
str1[0] = 'h';
str1[1] = 'i';
str1[2] = '\0';
len = strlen(str1);
printf("%s %d\n", str1, len); // prints: hi 2
strcpy(str2, str1); // copies the contents of str1 to str2
printf("%s\n", str2); // prints: hi
strcpy(str2, "hello"); // copy the string "hello" to str2
len = strlen(str2);
printf("%s has %d chars\n", str2, len); // prints: hello has 5 chars
}
The strlen function in the C string library returns the number of characters
in its string argument. A string’s terminating null character doesn’t count as
part of the string’s length, so the call to strlen(str1) returns 2 (the
length of the string "hi"). The strcpy function copies one character at a
time from a source string (the second parameter) to a destination string (the
first parameter) until it reaches a null character in the source.
Note that most C string library functions expect the call to pass in a
character array that has enough capacity for the function to perform its job.
For example, you wouldn’t want to call strcpy with a destination string that
isn’t large enough to contain the source; doing so will lead to undefined
behavior in your program!
C string library functions also require that string values passed to them are
correctly formed, with a terminating '\0' character. It’s up to you
as the C programmer to ensure that you pass in valid strings for C library
functions to manipulate. Thus, in the call to strcpy in the preceding example, if the
source string (str1) was not initialized to have a terminating '\0'
character, strcpy would continue beyond the end of the str1 array’s bounds,
leading to undefined behavior that could cause it to crash.
|
The previous example uses the We chose to show |
In the next chapter, we discuss C strings and the C string library in more detail.
1.6. Structs
Arrays and structs are the two ways in which C supports creating collections of data elements. Arrays are used to create an ordered collection of data elements of the same type, whereas structs are used to create a collection of data elements of different types. A C programmer can combine array and struct building blocks in many different ways to create more complex data types and structures. This section introduces structs, and in the next chapter we characterize structs in more detail and show how you can combine them with arrays.
C is not an object-oriented language; thus, it doesn’t support classes. It
does, however, support defining structured types, which are like the data part
of classes. A struct is a type used to represent a heterogeneous collection
of data; it’s a mechanism for treating a set of different types as a single,
coherent unit. C structs provide a level of abstraction on top of individual
data values, treating them as a single type. For example, a student has a
name, age, grade point average (GPA), and graduation year. A programmer could
define a new struct type to combine those four data elements into a single
struct student variable that contains a name value (type char [], to hold a
string), an age value (type int), a GPA value (type float), and a
graduation year value (type int). A single variable of this struct type can
store all four pieces of data for a particular student; for example, ("Freya",
19, 3.7, 2021).
There are three steps to defining and using struct types in C programs:
-
Define a new
structtype that represents the structure. -
Declare variables of the new
structtype. -
Use dot (
.) notation to access individual field values of the variable.
1.6.1. Defining a Struct Type
A struct type definition should appear outside of any function, typically
near the top of the program’s .c file. The syntax for defining a new struct
type is the following (struct is a reserved keyword):
struct <struct_name> {
<field 1 type> <field 1 name>;
<field 2 type> <field 2 name>;
<field 3 type> <field 3 name>;
...
};
Here’s an example of defining a new struct studentT type for storing student
data:
struct studentT {
char name[64];
int age;
float gpa;
int grad_yr;
};
This struct definition adds a new type to C’s type system, and the type’s name is
struct studentT. This struct defines four fields, and each field definition
includes the type and name of the field. Note that in this example, the name
field’s type is a character array, for
use
as a string.
1.6.2. Declaring Variables of Struct Types
Once the type has been defined, you can declare variables of the new type,
struct studentT. Note that unlike the other types we’ve encountered so far
that consist of just a single word (for example, int, char, and float),
the name of our new struct type is two words, struct studentT.
struct studentT student1, student2; // student1, student2 are struct studentT
1.6.3. Accessing Field Values
To access field values in a struct variable, use dot notation:
<variable name>.<field name>
When accessing structs and their fields, carefully consider the types of the
variables you’re using. Novice C programmers often introduce bugs into their
programs by failing to account for the types of struct fields.
Table 9 shows the types of several expressions surrounding our
struct studentT type.
| Expression | C type |
|---|---|
|
|
|
integer ( |
|
array of characters ( |
|
character ( |
Here are some examples of assigning a struct studentT variable’s fields:
// The 'name' field is an array of characters, so we can use the 'strcpy'
// string library function to fill in the array with a string value.
strcpy(student1.name, "Kwame Salter");
// The 'age' field is an integer.
student1.age = 18 + 2;
// The 'gpa' field is a float.
student1.gpa = 3.5;
// The 'grad_yr' field is an int
student1.grad_yr = 2020;
student2.grad_yr = student1.grad_yr;
Figure 8 illustrates the layout of the student1 variable in
memory after the field assignments in the preceding example. Only the struct
variable’s fields (the areas in boxes) are stored in memory. The field names
are labeled on the figure for clarity, but to the C compiler, fields are simply
storage locations or offsets from the start of the struct variable’s memory.
For example, based on the definition of a struct studentT, the compiler knows
that to access the field named gpa, it must skip past an array of 64
characters (name) and one integer (age). Note that in the figure, the
name field only depicts the first six characters of the 64-character array.
C struct types are lvalues, meaning they can appear on the left side of an assignment statement. Thus, a struct variable can be assigned the value of another struct variable using a simple assignment statement. The field values of the struct on the right side of the assignment statement are copied to the field values of the struct on the left side of the assignment statement. In other words, the contents of memory of one struct are copied to the memory of the other. Here’s an example of assigning a struct’s values in this way:
student2 = student1; // student2 gets the value of student1
// (student1's field values are copied to
// corresponding field values of student2)
strcpy(student2.name, "Frances Allen"); // change one field value
Figure 9 shows the values of the two student variables after the
assignment statement and call to strcpy have executed. Note that the figure
depicts the name fields as the string values they contain rather than the
full array of 64 characters.
C provides a sizeof operator that takes a type and returns the number of
bytes used by the type. The sizeof operator can be used on any C type,
including struct types, to see how much memory space a variable of that type
needs. For example, we can print the size of a struct studentT type:
// Note: the `%lu` format placeholder specifies an unsigned long value.
printf("number of bytes in student struct: %lu\n", sizeof(struct studentT));
When run, this line should print out a value of at least 76 bytes, because 64
characters are in the name array (1 byte for each char), 4 bytes for the
int age field, 4 bytes for the float gpa field, and 4 bytes for the
int grad_yr field. The exact number of bytes might be larger than 76 on
some machines.
Here’s a full example program that
defines and demonstrates the use of our struct studentT type:
#include <stdio.h>
#include <string.h>
// Define a new type: struct studentT
// Note that struct definitions should be outside function bodies.
struct studentT {
char name[64];
int age;
float gpa;
int grad_yr;
};
int main(void) {
struct studentT student1, student2;
strcpy(student1.name, "Kwame Salter"); // name field is a char array
student1.age = 18 + 2; // age field is an int
student1.gpa = 3.5; // gpa field is a float
student1.grad_yr = 2020; // grad_yr field is an int
/* Note: printf doesn't have a format placeholder for printing a
* struct studentT (a type we defined). Instead, we'll need to
* individually pass each field to printf. */
printf("name: %s age: %d gpa: %g, year: %d\n",
student1.name, student1.age, student1.gpa, student1.grad_yr);
/* Copy all the field values of student1 into student2. */
student2 = student1;
/* Make a few changes to the student2 variable. */
strcpy(student2.name, "Frances Allen");
student2.grad_yr = student1.grad_yr + 1;
/* Print the fields of student2. */
printf("name: %s age: %d gpa: %g, year: %d\n",
student2.name, student2.age, student2.gpa, student2.grad_yr);
/* Print the size of the struct studentT type. */
printf("number of bytes in student struct: %lu\n", sizeof(struct studentT));
return 0;
}
When run, this program outputs the following:
name: Kwame Salter age: 20 gpa: 3.5, year: 2020 name: Frances Allen age: 20 gpa: 3.5, year: 2021 number of bytes in student struct: 76
1.6.4. Passing Structs to Functions
In C, arguments of all types are passed by value to functions. Thus, if a function has a struct type parameter, then when called with a struct argument, the argument’s value is passed to its parameter, meaning that the parameter gets a copy of its argument’s value. The value of a struct variable is the contents of its memory, which is why we can assign the fields of one struct to be the same as another struct in a single assignment statement like this:
student2 = student1;
Because the value of a struct variable represents the full contents of its memory, passing a struct as an argument to a function gives the parameter a copy of all the argument struct’s field values. If the function changes the field values of a struct parameter, the changes to the parameter’s field values have no effect on the corresponding field values of the argument. That is, changes to the parameter’s fields only modify values in the parameter’s memory locations for those fields, not in the argument’s memory locations for those fields.
Here’s a full example program using the
checkID function that takes a struct parameter:
#include <stdio.h>
#include <string.h>
/* struct type definition: */
struct studentT {
char name[64];
int age;
float gpa;
int grad_yr;
};
/* function prototype (prototype: a declaration of the
* checkID function so that main can call it, its full
* definition is listed after main function in the file):
*/
int checkID(struct studentT s1, int min_age);
int main(void) {
int can_vote;
struct studentT student;
strcpy(student.name, "Ruth");
student.age = 17;
student.gpa = 3.5;
student.grad_yr = 2021;
can_vote = checkID(student, 18);
if (can_vote) {
printf("%s is %d years old and can vote.\n",
student.name, student.age);
} else {
printf("%s is only %d years old and cannot vote.\n",
student.name, student.age);
}
return 0;
}
/* check if a student is at least the min age
* s: a student
* min_age: a minimum age value to test
* returns: 1 if the student is min_age or older, 0 otherwise
*/
int checkID(struct studentT s, int min_age) {
int ret = 1; // initialize the return value to 1 (true)
if (s.age < min_age) {
ret = 0; // update the return value to 0 (false)
// let's try changing the student's age
s.age = min_age + 1;
}
printf("%s is %d years old\n", s.name, s.age);
return ret;
}
When main calls checkID, the value of the student struct
(a copy of the memory contents of all its fields) is passed to the
s parameter. When the function changes the value of its parameter’s
age field, it doesn’t affect the age field of its argument (student).
This behavior can be seen by running the program, which outputs the following:
Ruth is 19 years old Ruth is only 17 years old and cannot vote.
The output shows that when checkID prints the age field, it reflects the
function’s change to the age field of the parameter s. However, after the
function call returns, main prints the age field of student with the same
value it had prior to the checkID call. Figure 10 illustrates the
contents of the call stack just before the checkID function returns.
Understanding the pass-by-value semantics of struct parameters is particularly
important when a struct contains a statically declared array field (like the
name field in struct studentT). When such a struct is passed to a
function, the struct argument’s entire memory contents, including every array
element in the array field, is copied to its parameter. If the parameter
struct’s array contents are changed by the function, those changes will not
persist after the function returns. This behavior might seem odd given what we
know about how arrays are
passed to functions, but it’s consistent with the struct-copying behavior
described earlier.
1.7. Summary
In this chapter, we introduced many parts of the C programming language by comparing them to similar language constructs in Python, a language that many readers might know. C has similar language features to those of many other high-level imperative and object-oriented programming languages, including variables, loops, conditionals, functions, and I/O. Some key differences between the C and Python features we discussed include C requiring that all variables be declared of a specific type before they’re used, and that C arrays and strings are a lower-level abstraction than Python’s lists and strings. The lower-level abstractions allow a C programmer more control over how their program accesses its memory and thus more control over their program’s efficiency.
In the next chapter, we cover the C programming language in detail. We revisit in more depth the many language features presented in this chapter, and we introduce some new C language features, most notably C pointer variables and support for dynamic memory allocation.
2. A Deeper Dive into C Programming
With many of the basics of C programming covered in the previous chapter, we now dive deeper into the details of C. In this chapter we revisit many of the topics from the previous chapter, such as arrays, strings, and structs, discussing them in more detail. We also introduce C’s pointer variables and dynamic memory allocation. Pointers provide a level of indirection to accessing program state, and dynamic memory allocation allows a program to adjust to changes in size and space needs as it runs, allocating more space as it needs it and freeing space it no longer needs. By understanding how and when to use pointer variables and dynamic memory allocation, a C programmer can design programs that are both powerful and efficient.
We begin with a discussion of the parts of program memory, as this will help in understanding many of the topics presented later. As the chapter progresses, we cover C file I/O and some advanced C topics including library linking and compiling to assembly code.
2.1. Parts of Program Memory and Scope
The following C program shows examples of functions, parameters, and local and global variables (function comments are omitted to shorten this code listing):
/* An example C program with local and global variables */
#include <stdio.h>
int max(int n1, int n2); /* function prototypes */
int change(int amt);
int g_x; /* global variable: declared outside function bodies */
int main(void) {
int x, result; /* local variables: declared inside function bodies */
printf("Enter a value: ");
scanf("%d", &x);
g_x = 10; /* global variables can be accessed in any function */
result = max(g_x, x);
printf("%d is the largest of %d and %d\n", result, g_x, x);
result = change(10);
printf("g_x's value was %d and now is %d\n", result, g_x);
return 0;
}
int max(int n1, int n2) { /* function with two parameters */
int val; /* local variable */
val = n1;
if ( n2 > n1 ) {
val = n2;
}
return val;
}
int change(int amt) {
int val;
val = g_x; /* global variables can be accessed in any function */
g_x += amt;
return val;
}
This example shows program variables with different scope. A variable’s scope defines when its name has meaning. In other words, scope defines the set of program code blocks in which a variable is bound to (associated with) a program memory location and can be used by program code.
Declaring a variable outside of any function body creates a global variable. Global variables remain permanently in scope and can be used by any code in the program because they’re always bound to one specific memory location. Every global variable must have a unique name — its name uniquely identifies a specific storage location in program memory for the entire duration of the program.
Local variables and parameters are only in scope inside the function
in which they are defined. For example, the amt parameter
is in scope only inside the change function. This means that only
statements within the change function body can access the amt
parameter, and an instance of the amt parameter is bound to a
specific memory storage location only within a specific active
execution of the function. Space to store a parameter’s
value is allocated on the
stack when the function gets called, and it is deallocated from the stack
when the function returns. Each activation of a function gets its own
bindings for its parameters and local variables. Thus, for recursive
function calls, each call (or activation) gets a separate stack
frame containing space for its parameters and local variables.
Because parameters and
local variables are only in scope inside the function that defines them,
different functions can use the same names for local variables and
parameters. For example, both
the change and the max functions have a local variable named val.
When code in the max function refers to val it refers to its
local variable val and not to the change function’s local variable
val (which is not in scope inside the max function.)
While there may occasionally be times when using global variables in C programs is necessary, we strongly recommend that you avoid programming with global variables whenever possible. Using only local variables and parameters yields code that’s more modular, more general-purpose, and easier to debug. Also, because a function’s parameters and local variables are only allocated in program memory when the function is active, they may result in more space-efficient programs.
Upon launching a new program, the operating system allocates the new program’s address space. A program’s address space (or memory space) represents storage locations for everything it needs in its execution, namely storage for its instructions and data. A program’s address space can be thought of as an array of addressable bytes; each used address in the program’s address space stores all or part of a program instruction or data value (or some additional state necessary for the program’s execution).
A program’s memory space is divided into several parts, each of which is used to store a different kind of entity in the process’s address space. Figure 11 illustrates the parts of a program’s memory space.
The top of a program’s memory is reserved for use by the operating system, but
the remaining parts are usable by the running program. The program’s
instructions are stored in the code section of the memory. For example, the
program listed above stores instructions for the main, max, and change
functions in this region of memory.
Local variables and parameters reside in the portion of memory for the stack. Because the amount of stack space grows and shrinks over the program’s execution as functions are called and returned from, the stack part of memory is typically allocated near the bottom of memory (at the highest memory addresses) to leave space for it to change. Stack storage space for local variables and parameters exists only when the function is active (within the stack frame for the function’s activation on the stack.)
Global variables are stored in the data section. Unlike the stack, the data region does not grow or shrink — storage space for globals persists for the entire run of the program.
Finally, the heap portion of memory is the part of a program’s address space associated with dynamic memory allocation. The heap is typically located far from stack memory, and grows into higher addresses as more space is dynamically allocated by the running program.
2.2. C’s Pointer Variables
C’s pointer variables provide a level of indirection to accessing program memory. By understanding how to use pointer variables, a programmer can write C programs that are both powerful and efficient. For example, through pointer variables, a C programmer can:
-
implement functions whose parameters can modify values in the caller’s stack frame
-
dynamically allocate (and deallocate) program memory at runtime when the program needs it
-
efficiently pass large data structures to functions
-
create linked dynamic data structures
-
interpret bytes of program memory in different ways.
In this section we introduce the syntax and semantics of C’s pointer variables and introduce common examples of how to use them in C programs.
2.2.1. Pointer Variables
A pointer variable stores the address of a memory location in which a value
of a specific type can be stored. For example, a pointer variable can store
the value of an int address at which the integer value 12 is stored. The
pointer variable points to (refers to) the value. A pointer provides a
level of indirection for accessing values stored in memory. Figure 12
illustrates an example of what a pointer variable might look like in memory:
Through the pointer variable, ptr, the value (12) stored in the memory
location it points to can be indirectly accessed.
C programs most frequently use pointer variables for:
-
"Pass by pointer" parameters, for writing functions that can modify their argument’s value through a pointer parameter
-
Dynamic memory allocation, for writing programs that allocate (and free) space as the program runs. Dynamic memory is commonly used for dynamically allocating arrays. It is useful when a programmer doesn’t know the size of a data structure at compile time (e.g., the array size depends on user input at runtime). It also enables data structures to be resized as the program runs.
Rules for Using Pointer Variables
The rules for using pointer variables are similar to regular variables, except that you need to think about two types: the type of the pointer variable, and the type stored in the memory address to which the pointer variable points.
-
First, declare a pointer variable using
type_name *var_name:int *ptr; // stores the memory address of an int (ptr "points to" an int) char *cptr; // stores the memory address of a char (cptr "points to" a char)Pointer TypesNote that although
ptrandcptrare both pointers, they refer to different types:-
The type of
ptris "pointer to int" (int *). It can point to a memory location that stores anintvalue. -
The type of
cptris "pointer to char" (char *). It can point to a memory location that stores acharvalue.
-
-
Next, initialize the pointer variable (make it point to something). Pointer variables store address values. A pointer should be initialized to store the address of a memory location whose type matches the type to which the pointer variable points. One way to initialize a pointer is to use the address operator (
&) with a variable to get the variable’s address value:int x; char ch; ptr = &x; // ptr gets the address of x, pointer "points to" x cptr = &ch; // cptr gets the address of ch, pointer "points to" ch
Figure 13. A program can initialize a pointer by assigning it the address of an existing variable of the appropriate type.Here’s an example of an invalid pointer initialization due to mismatched types:
cptr = &x; // ERROR: cptr can hold a char memory location // (&x is the address of an int)Even though the C compiler may allow this type of assignment (with a warning about incompatible types), the behavior of accessing and modifying
xthroughcptrwill likely not behave as the programmer expects. Instead, the programmer should use anint *variable to point to anintstorage location.All pointer variables can also be assigned a special value, NULL, which represents an invalid address. While a null pointer (one whose value is
NULL) should never be used to access memory, the valueNULLis useful for testing a pointer variable to see if it points to a valid memory address. That is, C programmers will commonly check a pointer to ensure that its value isn’tNULLbefore attempting to access the memory location to which it points. To set a pointer toNULL:ptr = NULL; cptr = NULL;
Figure 14. Any pointer can be given the special value NULL, which indicates that it doesn’t refer to any particular address. Null pointers should never be dereferenced.
-
Finally, use the pointer variable: the dereference operator (
*) follows a pointer variable to the location in memory that it points to and accesses the value at that location:/* Assuming an integer named x has already been declared, this code sets the value of x to 8. */ ptr = &x; /* initialize ptr to the address of x (ptr points to variable x) */ *ptr = 8; /* the memory location ptr points to is assigned 8 */
Figure 15. Dereferencing a pointer accesses the value to which the pointer refers.
Pointer Examples
Here’s an example sequence of C statements using two pointer variables:
int *ptr1, *ptr2, x, y;
x = 8;
ptr2 = &x; // ptr2 is assigned the address of x
ptr1 = NULL;
*ptr2 = 10; // the memory location ptr2 points to is assigned 10
y = *ptr2 + 3; // y is assigned what ptr2 points to plus 3
ptr1 = ptr2; // ptr1 gets the address value stored in ptr2 (both point to x)
*ptr1 = 100;
ptr1 = &y; // change ptr1's value (change what it points to)
*ptr1 = 80;
When using pointer variables, carefully consider the types of the relevant
variables. Drawing pictures of memory (like those shown above) can help with
understanding what pointer code is doing. Some common errors involve misusing the
dereference operator (*) or the address operator (&). For example:
ptr = 20; // ERROR?: this assigns ptr to point to address 20
ptr = &x;
*ptr = 20; // CORRECT: this assigns 20 to the memory pointed to by ptr
If your program dereferences a pointer variable that does not contain a valid address, the program crashes:
ptr = NULL;
*ptr = 6; // CRASH! program crashes with a segfault (a memory fault)
ptr = 20;
*ptr = 6; // CRASH! segfault (20 is not a valid address)
ptr = x;
*ptr = 6; // likely CRASH or may set some memory location with 6
// (depends on the value of x which is used as an address value)
ptr = &x; // This is probably what the programmer intended
*ptr = 6;
These types of errors exemplify one reason to initialize pointer variables to
NULL; a program can then test a pointer’s value for NULL before
dereferencing it:
if (ptr != NULL) {
*ptr = 6;
}
2.3. Pointers and Functions
Pointer parameters provide a mechanism through which functions can modify argument values. The commonly used pass by pointer pattern uses a pointer function parameter that gets the value of the address of some storage location passed to it by the caller. For example, the caller could pass the address of one of its local variables. By dereferencing the pointer parameter inside the function, the function can modify the value at the storage location to which it points.
We have already seen similar functionality with array parameters, where an array function parameter gets the value of the base address of the passed array (the parameter refers to the same set of array elements as its argument), and the function can modify the values stored in the array. In general, this same idea can be applied by passing pointer parameters to functions that point to the memory locations in the caller’s scope.
|
Pass by Value
All arguments in C are passed by value and follow pass-by-value
semantics: the parameter gets a copy of its argument value, and
modifying the parameter’s value does not change its argument’s value.
When passing base type values, like the value of an In the pass-by-pointer pattern, the parameter still gets the value of its argument, but it is passed the value of an address. Just like in passing base types, changing a pointer parameter’s value will not change its argument’s value (that is, assigning the parameter to point to a different address will not change the argument’s address value). However, by dereferencing a pointer parameter, the function can change the contents of memory that both the parameter and its argument refer to; through a pointer parameter, a function can modify a variable that is visible to the caller after the function returns. |
Here are the steps for implementing and calling a function with a pass by pointer parameter, with example code snippets showing each step:
-
Declare the function parameter to be a pointer to the variable type:
/* input: an int pointer that stores the address of a memory * location that can store an int value (it points to an int) */ int change_value(int *input) { -
When making the function call, pass in the address of a variable as the argument:
int x; change_value(&x);In the preceding example, since the parameter’s type is
int *, the address passed must be the address of anintvariable. -
In the body of the function, dereference the pointer parameter to change the argument’s value:
*input = 100; // the location input points to (x's memory) is assigned 100
Next, let’s examine a larger example program:
#include <stdio.h>
int change_value(int *input);
int main(void) {
int x;
int y;
x = 30;
y = change_value(&x);
printf("x: %d y: %d\n", x, y); // prints x: 100 y: 30
return 0;
}
/*
* changes the value of the argument
* input: a pointer to the value to change
* returns: the original value of the argument
*/
int change_value(int *input) {
int val;
val = *input; /* val gets the value input points to */
if (val < 100) {
*input = 100; /* the value input points to gets 100 */
} else {
*input = val * 2;
}
return val;
}
When run, the output is:
x: 100 y: 30
Figure 16 shows what the call stack looks like before executing the
return in change_value.
The input parameter gets a copy of the value of its argument (the address of
x). The value of x is 30 when the function call is made. Inside the
change_value function, the parameter is dereferenced to assign the value 100
to the memory location pointed to by the parameter (*input = 100;, meaning
"the location input points to gets the value 100"). Since the parameter
stores the address of a local variable in the main function’s stack frame,
through dereferencing the parameter, the value stored in the caller’s local
variable can be changed. When the function returns, the argument’s value
reflects the change made to it through the pointer parameter (the value of x
in main was changed to 100 by the change_value function through its input
parameter).
2.4. Dynamic Memory Allocation
In addition to pass-by-pointer parameters, programs commonly use pointer variables to dynamically allocate memory. Such dynamic memory allocation allows a C program to request more memory as it’s running, and a pointer variable stores the address of the dynamically allocated space. Programs often allocate memory dynamically to tailor the size of an array for a particular run.
Dynamic memory allocation grants flexibility to programs that:
-
do not know the size of arrays or other data structures until runtime (e.g. the size depends on user input)
-
need to allow for a variety of input sizes (not just up to some fixed capacity)
-
want to allocate exactly the size of data structures needed for a particular execution (don’t waste capacity)
-
grow or shrink the sizes of memory allocated as the program runs, reallocating more space when needed and freeing up space when it’s no longer required.
2.4.1. Heap Memory
Every byte of memory in a program’s memory space has an associated address. Everything the program needs to run is in its memory space, and different types of entities reside in different parts of a program’s memory space. For example, the code region contains the program’s instructions, global variables reside in the data region, local variables and parameters occupy the stack, and dynamically allocated memory comes from the heap. Because the stack and the heap grow at runtime (as functions are called and return and as dynamic memory is allocated and freed), they are typically far apart in a program’s address space to leave a large amount of space for each to grow into as the program runs.
Dynamically allocated memory occupies the heap memory region of a program’s address space. When a program dynamically requests memory at runtime, the heap provides a chunk of memory whose address must be assigned to a pointer variable.
Figure 17 illustrates the parts of a running program’s memory with an
example of a pointer variable (ptr) on the stack that stores the
address of dynamically allocated heap memory (it points to heap memory).
It’s important to remember that heap memory is anonymous memory, where "anonymous" means that addresses in the heap are not bound to variable names. Declaring a named program variable allocates it on the stack or in the data part of program memory. A local or global pointer variable can store the address of an anonymous heap memory location (e.g. a local pointer variable on the stack can point to heap memory), and dereferencing such a pointer enables a program to store data in the heap.
2.4.2. malloc and free
malloc and free are functions in the standard C library (stdlib)
that a program can call to allocate and deallocate memory in the heap.
Heap memory must be explicitly allocated (malloc’ed) and deallocated (freed)
by a C program.
To allocate heap memory, call malloc, passing in the total number of bytes
of contiguous heap memory to allocate. Use the sizeof operator to compute
the number of bytes to request. For example, to allocate space on the heap to
store a single integer, a program could call:
// Determine the size of an integer and allocate that much heap space.
malloc(sizeof(int));
The malloc function returns the base address of the allocated heap memory to
the caller (or NULL if an error occurs). Here’s a full example program with a call to
malloc to allocate heap space to store a single int value:
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p;
p = malloc(sizeof(int)); // allocate heap memory for storing an int
if (p != NULL) {
*p = 6; // the heap memory p points to gets the value 6
}
}
The malloc function returns a void * type, which represents a generic
pointer to a non-specified type (or to any type). When a program calls
malloc and assigns the result to a pointer variable, the program associates
the allocated memory with the type of the pointer variable.
Sometimes you may see calls to malloc that explicitly recast its return type
from void * to match the type of the pointer variable. For example:
p = (int *) malloc(sizeof(int));
The (int *) before malloc tells the compiler that the void * type
returned by malloc will be used as an int * in this call (it recasts
the return type of malloc to an int *). We discuss
type recasting and the void * type in more detail later in this chapter.
A call to malloc fails if there is not enough free heap memory to satisfy
the requested number of bytes to allocate. Usually, malloc failing
indicates an error in the program such as passing malloc a very large request,
passing a negative number of bytes, or calling malloc in an infinite loop and
running out of heap memory. Because any call to malloc can fail, you should
always test its return value for NULL (indicating malloc failed) before
dereferencing the pointer value. Dereferencing a NULL pointer will cause your
program to crash! For example:
int *p;
p = malloc(sizeof(int));
if (p == NULL) {
printf("Bad malloc error\n");
exit(1); // exit the program and indicate error
}
*p = 6;
When a program no longer needs the heap memory it dynamically allocated with
malloc, it should explicitly deallocate the memory by calling the free
function. It’s also a good idea to set the pointer’s value to NULL after
calling free, so that if an error in the program causes it to be accidentally
dereferenced after the call to free, the program will crash rather than modify
parts of heap memory that have been reallocated by subsequent calls to malloc.
Such unintended memory references can result in undefined program behavior
that is often very difficult to debug, whereas a null pointer dereference will
fail immediately, making it a relatively easy bug to find and to fix.
free(p);
p = NULL;
2.4.3. Dynamically Allocated Arrays and Strings
C programmers often dynamically allocate memory to store arrays. A successful
call to malloc allocates one contiguous chunk of heap memory of the requested
size. It returns the address of the start of this chunk of memory to the
caller, making the returned address value suitable for the base address of a
dynamically allocated array in heap memory.
To dynamically allocate space for an array of elements, pass malloc the total
number of bytes in the desired array. That is, the program should request from
malloc the total number of bytes in each array element times the number of
elements in the array. Pass malloc an expression for the total number of bytes
in the form of sizeof(<type>) * <number of elements>. For example:
int *arr;
char *c_arr;
// allocate an array of 20 ints on the heap:
arr = malloc(sizeof(int) * 20);
// allocate an array of 10 chars on the heap:
c_arr = malloc(sizeof(char) * 10);
After the calls to malloc in this example, the int pointer variable arr
stores the base address of an array of 20 contiguous integer storage
locations in heap memory, and the c_arr char pointer variable stores the
base address of an array of 10 contiguous char storage locations in heap
memory. Figure 18 depicts what this might look like.
Note that while malloc returns a pointer to dynamically allocated space in
heap memory, C programs store the pointer to heap locations on the stack. The
pointer variables contain only the base address (the starting address) of the
array storage space in the heap. Just like statically declared arrays, the
memory locations for dynamically allocated arrays are in contiguous memory
locations. While a single call to malloc results in a chunk of memory of the
requested number of bytes being allocated, multiple calls to malloc will
not result in heap addresses that are contiguous (on most systems).
In the example above, the char array elements and the int array
elements may be at addresses that are far apart in the heap.
After dynamically allocating heap space for an array, a program can access the array through the pointer variable. Because the pointer variable’s value represents the base address of the array in the heap, we can use the same syntax to access elements in dynamically allocated arrays as we use to access elements in statically declared arrays. Here’s an example:
int i;
int s_array[20];
int *d_array;
d_array = malloc(sizeof(int) * 20);
if (d_array == NULL) {
printf("Error: malloc failed\n");
exit(1);
}
for (i=0; i < 20; i++) {
s_array[i] = i;
d_array[i] = i;
}
printf("%d %d \n", s_array[3], d_array[3]); // prints 3 3
It may not be obvious why the same syntax can be used for accessing elements in
dynamically allocated arrays as is used in accessing elements in statically
declared arrays. However, even though their types are different, the values of
s_array and d_array both evaluate to the base address of the array in
memory.
| Expression | Value | Type |
|---|---|---|
s_array |
base address of array in memory |
(static) array of int |
d_array |
base address of array in memory |
int pointer (int *) |
Because the names of both variables evaluate to the base address
of the array in memory (the address of the first element memory),
the semantics of the [i] syntax following the name of the variable remain
the same for both: [i] dereferences the int storage location at offset i
from the base address of the array in memory — it’s accessing the _i_th
element.
For most purposes, we recommend using the [i] syntax to access the elements
of a dynamically allocated array. However, programs can also use the pointer
dereferencing syntax (the * operator) to access array elements. For
example, placing a * in front of a pointer that refers to a
dynamically allocated array will dereference the pointer to access element 0 of
the array:
/* these two statements are identical: both put 8 in index 0 */
d_array[0] = 8; // put 8 in index 0 of the d_array
*d_array = 8; // in the location pointed to by d_array store 8
The Arrays section describes arrays in more detail, and the Pointer Arithmetic section discusses accessing array elements through pointer variables.
When a program is finished using a dynamically allocated array, it should call
free to deallocate the heap space. As mentioned earlier, we recommend
setting the pointer to NULL after freeing it:
free(arr);
arr = NULL;
free(c_arr);
c_arr = NULL;
free(d_array);
d_array = NULL;
2.4.4. Pointers to Heap Memory and Functions
When passing a dynamically allocated array to a function, the pointer variable
argument’s value is passed to the function (i.e., the base address of the
array in the heap is passed to the function). Thus, when passing either
statically declared or dynamically allocated arrays to functions, the parameter
gets exactly the same value — the base address of the array in memory. As
a result, the same function can be used for statically and dynamically
allocated arrays of the same type, and identical syntax can be used inside the
function for accessing array elements. The parameter declarations int *arr
and int arr[] are equivalent. However, by convention, the pointer syntax
tends to be used for functions that may be called with dynamically allocated
arrays:
int main(void) {
int *arr1;
arr1 = malloc(sizeof(int) * 10);
if (arr1 == NULL) {
printf("malloc error\n");
exit(1);
}
/* pass the value of arr1 (base address of array in heap) */
init_array(arr1, 10);
...
}
void init_array(int *arr, int size) {
int i;
for (i = 0; i < size; i++) {
arr[i] = i;
}
}
At the point just before returning from the init_array function, the contents
of memory will look like Figure 19. Note that when main passes
arr1 to init_array, it’s passing only the base address of the array. The
array’s large block of contiguous memory remains on the heap, and the function
can access it by dereferencing the arr pointer parameter. It also passes the
size of the array so that init_array knows how many elements to access.
2.5. Arrays in C
In the previous chapter we introduced statically declared one-dimensional C arrays and discussed the semantics of passing arrays to functions. In the dynamic memory allocation section of this chapter, we introduced dynamically allocated one dimensional arrays and discussed the semantics of passing them to functions.
In this section, we take a more in-depth look at arrays in C. We describe both statically and dynamically allocated arrays in more detail and discuss two-dimensional arrays.
2.5.1. Single-Dimensional Arrays
Statically Allocated
Before jumping into new content, we briefly summarize static arrays with an example. See the previous chapter for more detail on statically declared one-dimensional arrays.
Statically declared arrays are allocated either on the stack (for local variables) or in the data region of memory (for global variables). A programmer can declare an array variable by specifying its type (the type stored at each index) and its total capacity (number of elements).
When passing an array to a function, C copies the value of the base address to the parameter. That is, both the parameter and the argument refer to the same memory locations — the parameter pointer points to the argument’s array elements in memory. As a result, modifying the values stored in the array through an array parameter modifies the values stored in the argument array.
Here are some examples of static array declaration and use:
// declare arrays specifying their type and total capacity
float averages[30]; // array of float, 30 elements
char name[20]; // array of char, 20 elements
int i;
// access array elements
for (i = 0; i < 10; i++) {
averages[i] = 0.0 + i;
name[i] = 'a' + i;
}
name[10] = '\0'; // name is being used for storing a C-style string
// prints: 3 d abcdefghij
printf("%g %c %s\n", averages[3], name[3], name);
strcpy(name, "Hello");
printf("%s\n", name); // prints: Hello
Dynamically Allocated
In the Dynamic Memory Allocation section of this chapter, we introduced dynamically allocated one-dimensional arrays, including their access syntax and the syntax and semantics of passing dynamically allocated arrays to functions. Here, we present a short recap of that information with an example.
Calling the malloc function dynamically allocates an array on the heap at
runtime. The address of the allocated heap space can be assigned to a global
or local pointer variable, which then points to the first element of the array.
To dynamically allocate space, pass malloc the total number of bytes to
allocate for the array (using the sizeof operator to get the size of a
specific type). A single call to malloc allocates a contiguous chunk of heap
space of the requested size. For example:
// declare a pointer variable to point to allocated heap space
int *p_array;
double *d_array;
// call malloc to allocate the appropriate number of bytes for the array
p_array = malloc(sizeof(int) * 50); // allocate 50 ints
d_array = malloc(sizeof(double) * 100); // allocate 100 doubles
// always CHECK RETURN VALUE of functions and HANDLE ERROR return values
if ( (p_array == NULL) || (d_array == NULL) ) {
printf("ERROR: malloc failed!\n");
exit(1);
}
// use [] notation to access array elements
for (i = 0; i < 50; i++) {
p_array[i] = 0;
d_array[i] = 0.0;
}
// free heap space when done using it
free(p_array);
p_array = NULL;
free(d_array);
d_array = NULL;
Array Memory Layout
Whether an array is statically declared or dynamically allocated
via a single call to malloc, array elements represent contiguous
memory locations (addresses):
array [0]: base address array [1]: next address array [2]: next address ... ... array [99]: last address
The location of element i is at an offset i from the base address of the
array. The exact address of the ith element depends on the number of bytes of
the type stored in the array. For example, consider the following array
declarations:
int iarray[6]; // an array of six ints, each of which is four bytes
char carray[4]; // an array of four chars, each of which is one byte
The addresses of their individual array elements might look something like this:
addr element
---- -------
1230: iarray[0]
1234: iarray[1]
1238: iarray[2]
1242: iarray[3]
1246: iarray[4]
1250: iarray[5]
...
1280: carray[0]
1281: carray[1]
1282: carray[2]
1283: carray[3]
In this example, 1230 is the base address of iarray and 1280 the
base address of carray. Note that individual elements of each
array are allocated to contiguous memory addresses: each element
of iarray stores a 4-byte int value, so its element addresses differ
by 4, and each element of carray stores a 1-byte char value, so its
addresses differ by 1. There is no guarantee that the set of local
variables are allocated to contiguous memory locations on the stack
(hence, there could be a gap in the addresses between the end of
iarray and the start of carray, as shown in this example.)
|
Constants are often used when defining the total capacity of an array rather than using a literal numeric value. Constants are aliases for C literal values, and are used instead of literals to make the code easier to read and to allow for it to be more easily updated. See C Constants to learn more about defining and using C constants. Here is an example defining and using a constant (
|
2.5.2. Two-Dimensional Arrays
C supports multidimensional arrays, but we limit our discussion of multidimensional arrays to two-dimensional (2D) arrays, since 1D and 2D arrays are the most commonly used by C programmers.
Statically Allocated 2D Arrays
To statically declare a multidimensional array variable, specify the size of each dimension. For example:
int matrix[50][100];
short little[10][10];
Here, matrix is a 2D array of int values with 50 rows and 100 columns, and
little is a 2D array of short values with 10 rows and 10 columns.
To access an individual element, indicate both the row and the column index:
int val;
short num;
val = matrix[3][7]; // get int value in row 3, column 7 of matrix
num = little[8][4]; // get short value in row 8, column 4 of little
Figure 20 illustrates the 2D array as a matrix of integer values, where a specific element in the 2D array is indexed by row and column index values.
Programs often access the elements of a 2D array by iterating with
nested loops. For example, the following nested loop initializes all elements
in matrix to 0:
int i, j;
for (i = 0; i < 50; i++) { // for each row i
for (j = 0; j < 100; j++) { // iterate over each column element in row i
matrix[i][j] = 0;
}
}
Two-Dimensional Array Parameters
The same rules for passing one-dimensional array arguments to functions apply
to passing two-dimensional array arguments: the parameter gets the value of the
base address of the 2D array (&arr[0][0]). In other words, the parameter
points to the argument’s array elements and therefore the function can change
values stored in the passed array.
For multidimensional array parameters, you must indicate that the parameter is a multidimensional array, but you can leave the size of the first dimension unspecified (for good generic design). The sizes of other dimensions must be fully specified so that the compiler can generate the correct offsets into the array. Here’s a 2D example:
// a C constant definition: COLS is defined to be the value 100
#define COLS (100)
/*
* init_matrix: initializes the passed matrix elements to the
* product of their index values
* m: a 2D array (the column dimension must be 100)
* rows: the number of rows in the matrix
* return: does not return a value
*/
void init_matrix(int m[][COLS], int rows) {
int i, j;
for (i = 0; i < rows; i++) {
for (j = 0; j < COLS; j++) {
m[i][j] = i*j;
}
}
}
int main(void) {
int matrix[50][COLS];
int bigger[90][COLS];
init_matrix(matrix, 50);
init_matrix(bigger, 90);
...
Both the matrix and the bigger arrays can be passed as arguments to the
init_matrix function because they have the same column dimension as
the parameter definition.
|
The column dimension must be specified in the parameter definition of a 2D array so that the compiler can calculate the offset from the base address of the 2D array to the start of a particular row of elements. The offset calculation follows from the layout of 2D arrays in memory. |
Two-Dimensional Array Memory Layout
Statically allocated 2D arrays are arranged in memory in row-major order, meaning that all of row 0’s elements come first, followed by all of row 1’s elements, and so on. For example, given the following declaration of a 2D array of integers:
int arr[3][4]; // int array with 3 rows and 4 columns
its layout in memory might look like Figure 21.
Note that all array elements are allocated to contiguous memory addresses.
That is, the base address of the 2D array is the memory address of the [0][0]
element (&arr[0][0]), and subsequent elements are stored contiguously in
row-major order (e.g., the entirety of row 1 is followed immediately by the entirety
of row 2, and so on).
Dynamically Allocated 2D Arrays
Dynamically allocated 2D arrays can be allocated in two ways. For an NxM 2D array, either:
-
Make a single call to
malloc, allocating one large chunk of heap space to store all NxM array elements. -
Make multiple calls to
malloc, allocating an array of arrays. First, allocate a 1D array of N pointers to the element type, with a 1D array of pointers for each row in the 2D array. Then, allocate N 1D arrays of size M to store the set of column values for each row in the 2D array. Assign the addresses of each of these N arrays to the elements of the first array of N pointers.
The variable declarations, allocation code, and array element access syntax differ depending on which of these two methods a programmer chooses to use.
Method 1: Memory-Efficient Allocation
In this method, a single call to malloc allocates the total number of bytes
needed to store the NxM array of values. This method has the benefit
of being more memory efficient because the entire space for all NxM
elements will be allocated at once, in contiguous memory locations.
The call to malloc returns the starting address of the allocated space (the base
address of the array), which (like a 1D array) should be stored in a pointer
variable. In fact, there is no semantic difference between allocating a 1D or
2D array using this method: the call to malloc returns the starting address of a
contiguously allocated chunk of heap memory of the requested number of bytes.
Because allocation of a 2D array using this method looks just like allocation
for a 1D array, the programmer has to explicitly map 2D row and column indexing
on top of this single chunk of heap memory space (the compiler has no implicit
notion of rows or columns and thus cannot interpret double indexing syntax into
this malloc’ed space).
Here’s an example C code snippet that dynamically allocates a 2D array using method 1:
#define N 3
#define M 4
int main(void) {
int *two_d_array; // the type is a pointer to an int (the element type)
// allocate in a single malloc of N x M int-sized elements:
two_d_array = malloc(sizeof(int) * N * M);
if (two_d_array == NULL) {
printf("ERROR: malloc failed!\n");
exit(1);
}
...
Figure 22 shows an example of allocating a 2D array using this method
and illustrates what memory might look like after the call to malloc.
Like 1D dynamically allocated arrays, the pointer variable for a 2D array is
allocated on the stack. That pointer is then assigned the value returned by
the call to malloc, which represents the base address of the contiguous chunk
of NxM int storage locations in the heap memory.
Because this method uses a single chunk of malloc’ed space for the 2D array,
the memory allocation is as efficient as possible (it only requires one call to
malloc for the entire 2D array). It’s the more efficient way to access
memory due to all elements being located close together in contiguous memory,
with each access requiring only a single level of indirection from the pointer
variable.
However, the C compiler does not know the difference between a 2D or
1D array allocation using this method. As a result,
the double indexing syntax ([i][j]) of statically declared 2D arrays cannot
be used when allocating a 2D array using this method. Instead, the
programmer must explicitly compute the offset into the contiguous chunk
of heap memory using a function of row and column index values ([i*M + j],
where M is the column dimension).
Here’s an example of how a programmer would structure code to initialize all the elements of a 2D array:
// access using [] notation:
// cannot use [i][j] syntax because the compiler has no idea where the
// next row starts within this chunk of heap space, so the programmer
// must explicitly add a function of row and column index values
// (i*M+j) to map their 2D view of the space into the 1D chunk of memory
for (i = 0; i < N; i++) {
for (j = 0; j < M; j++) {
two_d_array[i*M + j] = 0;
}
}
Method 1 (Single malloc) and Function Parameters
The base address of an array of int types allocated via a single malloc is
a pointer to an int, so it can be passed to a function with an (int *)
parameter. Additionally, the function must be passed row and column dimensions
so that it can correctly compute offsets into the 2D array. For example:
/*
* initialize all elements in a 2D array to 0
* arr: the array
* rows: number of rows
* cols: number of columns
*/
void init2D(int *arr, int rows, int cols) {
int i, j;
for (i = 0; i < rows; i++) {
for (j = 0; j < cols; j++) {
arr[i*cols + j] = 0;
}
}
}
int main(void) {
int *array;
array = malloc(sizeof(int) * N